fix: tag extraction candidates with conversational grounding and drop constructed content#927
Conversation
0224aea to
0face4c
Compare
|
This PR currently conflicts with the latest Please rebase onto or merge the latest One item to keep in scope during the rebase: R1 found that the new |
…l grounding
Extraction has no notion of register: an invented rule stated inside a
game is textually indistinguishable from a durable user preference, so
the admission gate (which scores shape, not reality) happily promotes
in-fiction assertions to profile/preference facts. Add a "grounding"
field ("real" | "constructed") to the extraction output schema so the
extractor can flag content that only holds inside an in-conversation
constructed context (games, roleplay, drafted fiction, hypotheticals,
sample data). A real aside stated in passing during play is still
"real" and keeps its natural category.
This is prompt-only; the enforcement post-filter lands separately.
…e extraction-policy knob Two independent, deterministic filters in extractCandidates()'s validation loop: - Grounding post-filter (companion to the prompt change): a candidate tagged grounding:"constructed" is dropped unless its category is "events", and at most one constructed "events" note survives per extraction. Missing/non-string/unrecognized grounding values fail open to "real" so a model that ignores the field cannot regress extraction. Real-grounded candidates are never affected by this filter, regardless of category. - Scope-glob extraction-policy knob (config only, no host plumbing required): SmartExtractorConfig.extractionPolicy maps a scope or scope-glob to "full" (default, unchanged) | "episodic-only" (only "events"-class candidates survive, independent of grounding) | "none" (extraction skipped entirely, zero LLM calls). Resolved via the new exported resolveExtractionPolicy(); exact-string entries take priority over glob entries. Neither filter touches admission control or speaker attribution.
Covers: constructed game content dropped from profile/preferences/ entities/cases; at most one constructed episodic events note per extraction; a genuine real first-person aside stated during play survives (false-positive guard); a purely factual transcript is unchanged; malformed/missing grounding fails open to "real"; the prompt documents the grounding contract; and the scope-glob policy resolver plus its "none"/"episodic-only"/unmatched behavior through extractAndPersist. Fixtures are synthetic (agent-one/agent-two).
Adds the new suite to package.json's test script and to scripts/ci-test-manifest.mjs under core-regression, alongside the other smart-extractor regression suites.
…icy knob Compiled output for the extraction-prompts.ts grounding tag and the smart-extractor.ts post-filter + scope-glob extraction-policy knob.
The per-item grounding self-tags wobble: the same clean fixture produced all-real tags on one run and correct constructed tags on two others, so a filter that trusts per-item tags alone (failing open to real) lets mislabeled fiction land in durable registers. Counter this at three deterministic layers plus the admission judge: - Extraction prompt v2: the model judges a batch-level conversation_register (real, mixed, fiction) once per extraction, and the grounding rules now state that grounding is judged per item with no expected count per batch, that the one-constructed-events cap is a storage rule rather than a tagging quota, and that items sharing a fictional frame must share a grounding tag. Few-shot examples now show full batches in varied shapes, including an all-constructed mid-game batch and a mixed batch with a real out-of-character aside. - Filter enforcement: register fiction drops all durable candidates regardless of per-item tags and keeps at most one events note; a mixed or missing register runs a batch contradiction check that demotes real-tagged durables whenever a sibling candidate carries an explicit constructed tag; register real keeps current behavior. Missing registers fail toward scrutiny (mixed), never toward open. - Grounding propagation: candidates keep their grounding and register past the filter, into stored metadata (grounding, conversation_register) and into the admission audit record, creating a persisted trail of which stored rows used the constructed allowance. - Admission: constructed candidates targeting durable registers are rejected deterministically before any LLM call; the utility prompt now interpolates grounding and register, names all six memory registers correctly, and instructs near-zero durable scores for fiction-framed content; the type prior for durable categories is capped at the events prior when the batch register is fiction, so the 0.6-weight prior cannot launder fiction into profile or preferences. Legacy payloads without grounding metadata keep their existing behavior throughout. Red-proved: 9 of the new regression tests fail against the previous implementation and pass after it.
0face4c to
e993727
Compare
app3apps
left a comment
There was a problem hiding this comment.
Re-reviewed the rebased head e993727.
There is one functional regression to address before merge:
- In
src/smart-extractor.ts:971-980, once a non-real batch contains any constructed candidate, the contradiction guard removes every surviving durable candidate without checking that candidate's own grounding. A mixed conversation containing a real-grounded preference plus a constructed event therefore loses the genuine preference. That conflicts with the prompt contract that real asides in mixed conversations should survive under their natural durable category.
Please preserve real-grounded durable candidates and add a regression covering a mixed batch with both real and constructed candidates.
Verification note: the new 21-test grounding suite passes in isolation. The serial full suite hit the orchestrator's 180-second timeout before reaching that file; no assertion failure was observed. As a separate follow-up, extractionPolicy values should be validated because an unknown value currently fails open to full extraction.
Problem
Extraction has no notion of conversational register. An in-game rule, a persona's claim, or drafted fiction is textually indistinguishable from a durable user fact once it reaches the extraction LLM, so a well-formed invented rule gets extracted with exactly the same shape as a genuine preference. Downstream, anything that scores candidates by shape rather than reality (for example a utility/confidence-style admission heuristic) will tend to prefer the invented content, because in-fiction text is maximally grounded in the transcript and reads as durably useful.
The fix belongs at the source: the extractor should know, per candidate, whether the underlying assertion is actually about the real user/world or only true inside an in-conversation constructed context (a game, roleplay, drafted fiction, a hypothetical, or sample/test data being manipulated).
The grounding mechanism
buildExtractionPrompt(src/extraction-prompts.ts) now asks the model to tag every extracted memory with agroundingfield:"real": an assertion about the actual user, the real world, or this real working session, including a genuine first-person aside stated in passing during a game (for example, mentioning a real appointment while playing)."constructed": an assertion whose truth exists only inside an in-conversation constructed context: a game's rules/scores/bets, a persona's claims, drafted fiction, a hypothetical, or sample data.A deterministic post-filter in
SmartExtractor.extractCandidates's validation loop (src/smart-extractor.ts) then enforces the contract:"constructed"is dropped unless its category isevents.eventsnote survives per extraction (an episodic record that the real participants did the activity, e.g. "ran a puzzle exercise", not the invented rules/scores themselves)."real"is never affected by this filter, regardless of category, including a real aside stated during play.groundingvalue fails open to"real", so a model that ignores the new field cannot regress extraction behavior. This keeps the change fully backward compatible.The scope-glob extraction-policy knob
As a cheap, deterministic companion,
SmartExtractorConfig.extractionPolicyaccepts a map from a scope string (or glob, using*) to one of:"full"(default): today's behavior, unchanged."episodic-only": onlyevents-class candidates are kept, independent of grounding. Useful as a blunt backstop for scopes known to be pure play/roleplay."none": extraction is skipped entirely for that scope, with zero LLM calls.Resolution (
resolveExtractionPolicy, exported fromsrc/smart-extractor.ts) prefers an exact scope match over a glob match, and defaults unmatched scopes to"full". This knob needs no host-side plumbing: it is read entirely fromSmartExtractorConfig, keyed on scope strings the extractor already has in hand.Test coverage
New suite:
test/extraction-grounding-register.test.mjs(wired intopackage.json'stestscript and intoscripts/ci-test-manifest.mjsundercore-regression).preferences/cases, keeping only the single constructedeventsnote.eventscandidates in one extraction: only the first survives (cap enforcement).grounding-field path).groundingvalue (non-string, unrecognized string) fails open to"real"and is kept.buildExtractionPromptdocuments the grounding contract (structural check against prompt regressions).resolveExtractionPolicy: exact match wins over glob match; unmatched scope defaults to"full"."none": extraction is skipped with zero LLM calls."episodic-only": onlyevents-class candidates survive, independent of grounding."full".All fixtures are synthetic two-agent transcripts (
agent-one/agent-two) with invented game content.Non-goals
src/admission-control.ts) is untouched. Areality-style admission feature is a reasonable independent defense-in-depth follow-up, but it is downstream of this fix, costs a second per-candidate signal, and only helps deployments that enable admission control (off by default). Better evaluated after this change has had time to prove out.groundingand speaker attribution are orthogonal per-candidate filters in the same validation loop and are designed to stack without either blocking the other.extractionPolicythrough the plugin's own configuration file is left as a fast-follow; the knob is fully functional today viaSmartExtractorConfigfor any caller that constructsSmartExtractordirectly.Live fleet verification (2026-07-12)
Deployed this branch to a live gateway and probed it with a fresh two-turn webchat session against a fleet test agent:
Observed (extraction model: openai/gpt-oss-120b):
eventsnote about the roleplay activity itself; the model's reasoning applied the new grounding rules ("...seems like part of the roleplay persona. So it's constructed."). None of the five in-game propositions surfaced asprofile/preferences/entities/cases/patternscandidates.eventscandidate, showing the rule does not over-suppress genuine asides spoken around play.For contrast: before this change, an equivalent game session on the same fleet persisted in-game rules, scores, and persona claims as durable
preferenceandfactrows.v2 hardening (added after extended live testing)
Repeated runs of the same roleplay fixture surfaced a failure mode in v1: grounding self-tagging is not deterministic. On one of three identical-fixture runs, the model tagged three in-fiction canon items
real(matching the shape of the few-shot examples, which all showed the same three-real-plus-one-constructed pattern) while correctly tagging the session noteconstructed, a batch self-contradiction the deterministic filter could not catch because it trusts the per-item tags and fails open toreal.v2 therefore stops relying on per-item self-tags alone:
conversation_register: "real" | "mixed" | "fiction", judged once per batch (whole-session classification proved stable across runs where per-item tags wobbled). The filter enforces it deterministically:fictiondrops all durable candidates regardless of per-item tags and keeps at most the single constructed events note; a missing register is treated asmixed(toward scrutiny, not open).constructedtag in the batch demotes surviving real-tagged durables (deterministic, no extra LLM call). Tradeoff, stated plainly: a genuine durable aside made mid-fiction without an exit cue is sacrificed; such asides normally classify as events, and the demotion is audit-logged.grounding(and the batch register) past the filter into the admission evaluation and into persisted row metadata, so there is an audit trail of which stored events used the constructed allowance. Constructed candidates targeting durable registers are auto-rejected before any admission LLM call.v2 live verification
Deployed on a live gateway and re-probed with the original fixture: extraction produced exactly one real aside plus one constructed session note (both with persisted grounding and register metadata), zero fiction durables, an honest "I don't have that information" on the recall probe that previously returned the fictional answer, and an admission rejection whose logged reasoning explicitly cited the fictional frame. The deterministic layers are covered by red-proven unit tests reproducing the exact observed failure shape.