From 5c44fa695215e01340ec2898c222386c9f751d5c Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:42:06 -0400 Subject: [PATCH 1/6] fix(validator): reject mirrors that add or drop a negation Negators are function words, so lexical and span grounding both ignored them: "Money is not the main problem" validated as a mirror of "Money is the main problem", and a mirror could drop a "never" by citing the words on either side of it. Add a third per-claim check, polarity: a claim must be negated exactly when its cited wording is. Cited wording counts as negated if the userPhrase contains a negator or one appears in the two tokens just before it in the cited utterance. English only; Chinese negation characters also occur inside non-negative words, so a token list would misfire. A polarity failure follows the existing reflection_validation_failed repair path, with a message naming the fix. Co-Authored-By: Claude Opus 5.5 --- src/types.ts | 2 +- src/validator.test.ts | 41 +++++++++++++++++++++++++++ src/validator.ts | 64 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index 1c0640e..cc6c86d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,7 +114,7 @@ export interface GroundedRecap { claims: GroundedClaim[]; } -export type MirrorCheckName = "lexical_grounding" | "span_grounding"; +export type MirrorCheckName = "lexical_grounding" | "span_grounding" | "polarity"; /** A sub-part of a check, reported for transparency/calibration. */ export interface MirrorCheckPart { diff --git a/src/validator.test.ts b/src/validator.test.ts index 89580f7..414cb6a 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -505,3 +505,44 @@ describe("mirror validator — 2 grounding checks", () => { expect(result.ok).toBe(true); }); }); + +describe("mirror validator — polarity", () => { + const span = (text: string, utterance: SourceUtterance, phrase = text) => ({ claimText: text, utteranceIds: [utterance.id], userPhrase: phrase }); + + it("rejects a mirror that adds a negation the user never said", () => { + const said = u("Money is the main problem in my essay."); + for (const text of ["Money is not the main problem", "Money is never the main problem", "Money isn't the main problem"]) { + const result = validateMirror({ claims: [claim(text, [span(text, said, "Money is the main problem")])] }, [said], defaultConfig); + expect(result.ok, text).toBe(false); + expect(checkOf(result.claims[0], "polarity")?.ok, text).toBe(false); + expect(result.claims[0].message).toContain("negation"); + } + }); + + it("rejects a mirror that drops a negation inside the cited phrase", () => { + const said = u("Money isn't the main problem in my essay."); + const result = validateMirror({ claims: [claim("Money is the main problem", [span("Money is the main problem", said, "Money isn't the main problem")])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + }); + + it("rejects dropping a negation by citing the words around it", () => { + const said = u("Structure never matters to my readers."); + const result = validateMirror({ + claims: [claim("Structure matters to my readers", [span("Structure", said), span("matters to my readers", said)])], + }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + }); + + it("keeps faithful negated and unnegated mirrors passing", () => { + const negated = u("Money doesn't matter as much as time."); + const plain = u("Time matters most."); + expect(validateMirror({ claims: [claim("money doesn't matter as much as time", [span("money doesn't matter as much as time", negated, "Money doesn't matter as much as time")])] }, [negated], defaultConfig).ok).toBe(true); + expect(validateMirror({ claims: [claim("time matters most", [span("time matters most", plain, "Time matters most")])] }, [plain], defaultConfig).ok).toBe(true); + }); + + it("does not treat a negation elsewhere in the sentence as negating the cited phrase", () => { + const said = u("Money matters, not time."); + const result = validateMirror({ claims: [claim("money matters", [span("money matters", said, "Money matters")])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(true); + }); +}); diff --git a/src/validator.ts b/src/validator.ts index 8601b83..18fd53e 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -14,6 +14,9 @@ * free, but there is no unsupported-word budget. * 2. Source-span grounding — does every claim trace to a user utterance that * actually supports it? (catches new *relationships* built from real words) + * 3. Polarity — a claim is negated exactly when its cited wording is. Negators + * are function words, so checks 1 and 2 alone let a mirror add or drop a + * "not" and reverse the user's meaning while every content word matches. * * Checks run per-claim so the caller can show/confirm chunks independently and * knows exactly which span to ask about when one fails. @@ -23,6 +26,7 @@ import type { MindmapConfig } from "./config"; import { containsWholePhrase, contentTokens, + findWholePhraseRange, isStopword, normalize, stem, @@ -44,6 +48,56 @@ function ratio(part: number, whole: number): number { return whole === 0 ? 0 : part / whole; } +/** + * English negation markers, as produced by `tokenize`. Contractions split at the + * apostrophe ("isn't" → "isn", "t"), so the bare "t" marks every n't form; the + * apostrophe-less spellings are listed explicitly. Chinese negation is not + * covered here: its markers are characters inside words (不是, 没有) and also + * occur in non-negative words (非常), so a token list would misfire. + */ +const NEGATION_MARKERS = new Set([ + "not", "no", "never", "nor", "neither", "none", "nobody", "nothing", "nowhere", + "cannot", "without", "t", + "dont", "doesnt", "didnt", "isnt", "arent", "wasnt", "werent", "cant", "couldnt", + "wont", "wouldnt", "shouldnt", "hasnt", "havent", "hadnt", "aint", "mustnt", +]); + +function hasNegation(tokens: string[]): boolean { + return tokens.some((token) => NEGATION_MARKERS.has(token)); +} + +/** How many tokens before a cited phrase can negate it ("does not really matter"). */ +const NEGATION_LOOKBEHIND = 2; + +/** + * Is this cited span negated in the user's own words? Either the phrase itself + * contains a negator, or one sits just before it in a cited utterance. The + * second case stops a mirror from citing the words on either side of a "never" + * ("X" + "matters" out of "X never matters") to drop it. + */ +function spanIsNegated(span: SourceSpan, bank: Map): boolean { + if (hasNegation(tokenize(span.userPhrase))) return true; + return citedTexts(span, bank).some((source) => { + const range = findWholePhraseRange(source, span.userPhrase); + if (!range) return false; + return hasNegation(tokenize(source.slice(0, range.start)).slice(-NEGATION_LOOKBEHIND)); + }); +} + +/** + * Polarity — the claim is negated exactly when its cited wording is. Content-word + * matching can't see this: "Money is not the main problem" uses only the content + * words of "Money is the main problem". + */ +function checkPolarity( + claim: GroundedClaim, + bank: Map, +): MirrorCheckResult { + const claimNegated = hasNegation(tokenize(claim.text)); + const citedNegated = claim.sourceSpans.some((span) => spanIsNegated(span, bank)); + return { check: "polarity", ok: claimNegated === citedNegated, score: claimNegated === citedNegated ? 1 : 0, threshold: 1 }; +} + /** Look up the text of cited utterances; missing ids contribute nothing. */ function citedTexts(span: SourceSpan, bank: Map): string[] { return span.utteranceIds @@ -231,7 +285,9 @@ function validateClaim( const lexical = checkLexicalGrounding(claim, citedPhraseStemSet(claim)); const grounding = checkSpanGrounding(claim, bank, cfg.mirror.spanGroundingMin); - const checks = [lexical.result, grounding.result]; + const polarity = checkPolarity(claim, bank); + + const checks = [lexical.result, grounding.result, polarity]; const ok = checks.every((c) => c.ok); // The Clarify-Mode hint comes from the weakest span when grounding failed; @@ -239,9 +295,13 @@ function validateClaim( const weakestSpan = grounding.weakest ?? (ok ? undefined : claim.sourceSpans[0]); const failed = checks.filter((c) => !c.ok).map((c) => c.check); + // The polarity note goes to the repair call verbatim, so it names the fix. + const polarityNote = polarity.ok + ? "" + : " The claim's negation does not match the cited wording: keep a 'not'/'never'/n't exactly where the user said it, and never add one."; const message = ok ? "Reflection is grounded in the user's words." - : `Reflection not grounded in the user's words — failed checks: ${failed.join(", ")}.`; + : `Reflection not grounded in the user's words — failed checks: ${failed.join(", ")}.${polarityNote}`; return { claimId: claim.id, ok, checks, weakestSpan, ungroundedContentWords: lexical.ungroundedContentWords, message }; } From 1f8bc92cc03478b8f1122ab1d55035640fb273d5 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:43:17 -0400 Subject: [PATCH 2/6] fix(prompt): mirror explicitly stated structure instead of asking about it The prompt told the model to "favor one focusing question" for any large or abstract turn, and the context labels most outlines "large" (>=4 sentences or >=45 content words). Combined with sentence-level relation grounding, an outline usually couldn't be mirrored completely, so the coach asked instead. - New level-independent STATED STRUCTURE section: when the user lays out structure explicitly, mirror it that turn, one claim per idea, plus a relationship claim wherever the relationship and both ideas share one Source Bank sentence (it now says each [id] is one sentence). A relationship spread across sentences is left for the user, without blocking the rest of the mirror. Don't ask the user to restate what they already said clearly; turn length alone is never a reason to ask. - The large-turn rule now applies only to turns that don't state their structure, and only when no mirror of the main points is possible. - The L0 objective points at the new section. Grounding, contract and confirmation rules are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/api.test.ts | 9 ++++++++- src/api.ts | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index c5b5d37..fa65a95 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -196,7 +196,12 @@ describe("typed assistant response parser", () => { expect(first[0].content).toContain("a reflection may draw from only one recorded user moment"); expect(first[0].content).toContain("explicitly instructs you to nest one referenced card in another"); expect(first[0].content).toContain("Use direct quotation only when it makes the referent clearer"); - expect(first[0].content).toContain("For a large or abstract turn"); + expect(first[0].content).toContain("For a large or abstract turn that does not state its structure explicitly"); + // Stated structure is mirrored, not questioned; size alone never justifies a question. + expect(first[0].content).toContain("STATED STRUCTURE:"); + expect(first[0].content).toContain("mirror it in that turn rather than asking about it"); + expect(first[0].content).toContain("each [id] in the Source Bank is one sentence"); + expect(first[0].content).toContain("Turn length is never by itself a reason to ask instead of mirror."); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -237,6 +242,8 @@ describe("typed assistant response parser", () => { expect(messages[0].content).toContain("never invent a dangling candidateId"); expect(messages[0].content).toContain("one exact contiguous substring from DRAFT"); expect(messages[0].content).not.toContain("L0 NON-DIRECTIVE OBJECTIVE"); + // The stated-structure rule is level-independent. + expect(messages[0].content).toContain("STATED STRUCTURE:"); }); it("uses a bounded Responses tool repair with the matching call id", async () => { diff --git a/src/api.ts b/src/api.ts index d1d7c8f..486501e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -202,11 +202,14 @@ Make a useful, bounded contribution when the user explicitly asks for possibilit ? `L1 GROUNDED-OPTIONS OBJECTIVE: Help the user compare or organize possibilities already present in eligible user wording. Options must remain verbatim-grounded. Do not add a new lens, relationship, priority, or direction, including through a question's premise.` : `L0 NON-DIRECTIVE OBJECTIVE: -Increase clarity while preserving the user's conceptual freedom. Do not supply an umbrella concept, interpretation, causal or hierarchical relationship, priority, diagnosis, or direction the user has not stated, including as a presupposition inside a question. Prefer a faithful reflection when the user's meaning is already expressible in their words; otherwise ask one open, gap-filling question. A direct request for help does not authorize you to choose the answer. A map proposal is appropriate only when the user has already supplied the exact content and any claimed relationship.`; +Increase clarity while preserving the user's conceptual freedom. Do not supply an umbrella concept, interpretation, causal or hierarchical relationship, priority, diagnosis, or direction the user has not stated, including as a presupposition inside a question. Prefer a faithful reflection when the user's meaning is already expressible in their words, including structure they have stated (see STATED STRUCTURE); otherwise ask one open, gap-filling question. A direct request for help does not authorize you to choose the answer. A map proposal is appropriate only when the user has already supplied the exact content and any claimed relationship.`; return `You are a writing coach operating under the assistance contract supplied below. ${transportInstruction} Never infer authorization from imperative language: a map change is only a proposal. A contract never bypasses evidence, confirmation, or graph validation. ${levelObjective} +STATED STRUCTURE: +When the user explicitly lays out structure in a turn — a list or outline, numbered or "first/second" points, a main point with supporting points, or a relationship word such as "because", "leads to", "under", or "part of" — mirror it in that turn rather than asking about it. Give each stated idea its own reflection claim, and add a hierarchy or connection claim wherever the relationship and both of its ideas appear together in one Source Bank sentence (each [id] in the Source Bank is one sentence). If a relationship is spread across sentences, mirror its ideas now and leave that one relationship for the user to state or confirm. Do not ask the user to restate, confirm, or elaborate something they have already said clearly; the user confirms or declines each mirrored chunk, so mirroring clearly stated structure is never premature. After mirroring, ask about a piece only if it is genuinely missing or ambiguous. Turn length is never by itself a reason to ask instead of mirror. + EXPLICIT TRANSLATION: Only emit a translation response when the user directly asks you to translate. Treat that as a separate, visibly AI-authored service, never as a mirror, recap, suggestion, or map action. Select the exact original-language phrase or phrases being translated and identify each with sourceEvidence; preserve their original wording in the Source Bank and model context. Put the translated output only in translatedText, set provenance to "ai_translated", and name the requested targetLanguage. Do not create candidates, a proposal, a card, a relationship, or an adoption path from a translation. Do not translate proactively, and do not use translation to make evidence appear grounded. @@ -214,7 +217,7 @@ QUESTION FRAMING CHECK: A question is also a form of framing. Before emitting one, inspect every substantive concept, relationship, assumption, candidate answer, and direction it contains. If the user has not stated that material and it is not established in the confirmed map, do not smuggle it into the question. At L0/L1, remove it and ask about the genuine gap using the user's concepts. At L2, if the material would be a useful contribution, move it into an explicitly attributed suggestion; otherwise remove it. Do not turn an unstated answer into a forced choice. Use direct quotation only when it makes the referent clearer, and integrate quoted wording into a natural, understandable sentence. A fragment or pronoun copied exactly can still make a question confusing; name the user's established topic plainly without adding a substantive premise. Do not force the user to unpack a list of words one by one when a synthesis or broader gap is the useful next move. For example, after separate statements about visibility and redistribution, "Why does visibility lead to redistribution?" falsely assumes a relationship. L0 can ask, "How, if at all, do visibility and redistribution belong together for you?" L2 can instead say, "AI suggestion: visibility might be a condition for redistribution rather than the same change. Does that fit, or would you frame their relationship differently?" SHARED AUTHORSHIP AND GROUNDING: -In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment; so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn, when a complete grounded mirror is not yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} +In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment; so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} Schema: ${transport === "responses_tools" ? "For this transport, reflection and map_proposal entries describe normalized application responses only; emit them through their named tools. Text output is provider-constrained to question, aside, grounded_recap, options, suggestion, or translation." : ""} From 0bd85b33050912b4179ff80e62d9b8dc5e4bff97 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:50:16 -0400 Subject: [PATCH 3/6] fix(prompt): resolve conflicts around the stated-structure rule - State precedence: the rule holds at every assistance level and Think/Map setting and outranks the turn-shape, map-pacing and reflection-rhythm facts. The slider (default 25, Think-leaning) told the model to "tune conversational eagerness", which pulled against mirroring. - Clarify L0's "one recorded user moment" as one user turn that may contain several sentences and claims, so a multi-claim mirror of one turn doesn't read as forbidden. - Lines of a list are their own Source Bank entries too (segmentation splits on newlines), so say "one sentence, or one line of a list". - Defer to an explicit user request for something else (e.g. "Go deeper"). Co-Authored-By: Claude Opus 5.5 --- src/api.test.ts | 2 ++ src/api.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index fa65a95..f393075 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -202,6 +202,8 @@ describe("typed assistant response parser", () => { expect(first[0].content).toContain("mirror it in that turn rather than asking about it"); expect(first[0].content).toContain("each [id] in the Source Bank is one sentence"); expect(first[0].content).toContain("Turn length is never by itself a reason to ask instead of mirror."); + expect(first[0].content).toContain("takes precedence over the turn-shape, map-pacing, and reflection-rhythm facts"); + expect(first[0].content).toContain("one recorded user moment (one user turn, which may contain several sentences and several claims)"); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/src/api.ts b/src/api.ts index 486501e..99c815d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -208,7 +208,7 @@ Increase clarity while preserving the user's conceptual freedom. Do not supply a ${levelObjective} STATED STRUCTURE: -When the user explicitly lays out structure in a turn — a list or outline, numbered or "first/second" points, a main point with supporting points, or a relationship word such as "because", "leads to", "under", or "part of" — mirror it in that turn rather than asking about it. Give each stated idea its own reflection claim, and add a hierarchy or connection claim wherever the relationship and both of its ideas appear together in one Source Bank sentence (each [id] in the Source Bank is one sentence). If a relationship is spread across sentences, mirror its ideas now and leave that one relationship for the user to state or confirm. Do not ask the user to restate, confirm, or elaborate something they have already said clearly; the user confirms or declines each mirrored chunk, so mirroring clearly stated structure is never premature. After mirroring, ask about a piece only if it is genuinely missing or ambiguous. Turn length is never by itself a reason to ask instead of mirror. +When the user explicitly lays out structure in a turn — a list or outline, numbered or "first/second" points, a main point with supporting points, or a relationship word such as "because", "leads to", "under", or "part of" — mirror it in that turn rather than asking about it, unless the user explicitly asks for something else. Give each stated idea its own reflection claim, and add a hierarchy or connection claim wherever the relationship and both of its ideas appear together in one Source Bank sentence (each [id] in the Source Bank is one sentence, or one line of a list). If a relationship is spread across sentences or lines, mirror its ideas now and leave that one relationship for the user to state or confirm. Do not ask the user to restate, confirm, or elaborate something they have already said clearly; the user confirms or declines each mirrored chunk, so mirroring clearly stated structure is never premature. After mirroring, ask about a piece only if it is genuinely missing or ambiguous. Turn length is never by itself a reason to ask instead of mirror. This rule holds at every assistance level and every Think/Map setting, and takes precedence over the turn-shape, map-pacing, and reflection-rhythm facts in the context. EXPLICIT TRANSLATION: Only emit a translation response when the user directly asks you to translate. Treat that as a separate, visibly AI-authored service, never as a mirror, recap, suggestion, or map action. Select the exact original-language phrase or phrases being translated and identify each with sourceEvidence; preserve their original wording in the Source Bank and model context. Put the translated output only in translatedText, set provenance to "ai_translated", and name the requested targetLanguage. Do not create candidates, a proposal, a card, a relationship, or an adoption path from a translation. Do not translate proactively, and do not use translation to make evidence appear grounded. @@ -217,7 +217,7 @@ QUESTION FRAMING CHECK: A question is also a form of framing. Before emitting one, inspect every substantive concept, relationship, assumption, candidate answer, and direction it contains. If the user has not stated that material and it is not established in the confirmed map, do not smuggle it into the question. At L0/L1, remove it and ask about the genuine gap using the user's concepts. At L2, if the material would be a useful contribution, move it into an explicitly attributed suggestion; otherwise remove it. Do not turn an unstated answer into a forced choice. Use direct quotation only when it makes the referent clearer, and integrate quoted wording into a natural, understandable sentence. A fragment or pronoun copied exactly can still make a question confusing; name the user's established topic plainly without adding a substantive premise. Do not force the user to unpack a list of words one by one when a synthesis or broader gap is the useful next move. For example, after separate statements about visibility and redistribution, "Why does visibility lead to redistribution?" falsely assumes a relationship. L0 can ask, "How, if at all, do visibility and redistribution belong together for you?" L2 can instead say, "AI suggestion: visibility might be a condition for redistribution rather than the same change. Does that fit, or would you frame their relationship differently?" SHARED AUTHORSHIP AND GROUNDING: -In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment; so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} +In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} Schema: ${transport === "responses_tools" ? "For this transport, reflection and map_proposal entries describe normalized application responses only; emit them through their named tools. Text output is provider-constrained to question, aside, grounded_recap, options, suggestion, or translation." : ""} From 7474a0a26df18f876e7f8e3d2c77cda48635264e Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:36:27 -0400 Subject: [PATCH 4/6] fix(validator): narrow polarity to the negation words a claim cites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first polarity check read a two-token window before the cited phrase and treated every standalone "t" as a negator. That rejected faithful mirrors ("Not only money matters…", "Without delay, submit the draft", "Section T: …"), and two such rejections force a question, the outcome this branch is meant to prevent. Growing it into clause and idiom handling would put the kind of keyword bank that docs/refactor-plan.md §1.0 removed back into the enforcement path. Now negation words are simply taken out of the free-glue exemption: a claim must use exactly the negation words of the userPhrases it cites (n't, "not" and apostrophe-less spellings compare equal; n't is expanded with a literal split, no regex). This catches an added negation and one dropped from inside a cited phrase. A negation dropped by citing the words around it is a documented gap covered by per-chunk user confirmation. Also forwards the polarity hint on grounded-recap rejections, adds a prompt line telling the model to cite negating words, and records the constraint in docs/airtightness-report.md. Co-Authored-By: Claude Opus 5.5 --- docs/airtightness-report.md | 1 + src/api.test.ts | 1 + src/api.ts | 2 +- src/stage1-loop.ts | 5 +- src/validator.test.ts | 59 +++++++++++++++------- src/validator.ts | 97 +++++++++++++++++++------------------ 6 files changed, 97 insertions(+), 68 deletions(-) diff --git a/docs/airtightness-report.md b/docs/airtightness-report.md index ae4be2e..610e61d 100644 --- a/docs/airtightness-report.md +++ b/docs/airtightness-report.md @@ -37,6 +37,7 @@ action. No keyword/regex bank interprets user intent in the enforcement path. | Constraint | File(s) | Mechanism | | --- | --- | --- | | Reflections and recaps use the user's words | `validator.ts`, `stage1-loop.ts` | Every distinct substantive content word in each claim must be supported by cited eligible evidence; ordinary function-word glue is ignored. Visible text is reconstructed from validated claims rather than trusted from a model-authored wrapper. | +| Reflections and recaps keep the user's negation | `validator.ts`, `stage1-loop.ts` | Negation words (`not`/`no`/`never`/n't/`without`…) are taken out of the free-glue exemption: a claim must use exactly the negation words of the `userPhrase`s it cites (n't, `not` and apostrophe-less spellings compare equal). This is pointer verification, not meaning detection: no clause parsing or idiom list, so negation elsewhere in the user's sentence never blocks a mirror. Known gap: a negation dropped by citing the words on either side of it is not caught; the user's per-chunk confirmation covers it. English only. | | Relationships/hierarchy are stated by the user in one breath | `validator.ts` | Each cited span must ground `>= 0.75` of its phrase within a *single* utterance. For hierarchy/connection claims, the model declares the literal `relationSpan` connective; code verifies it is a verbatim substring of both the claim text and one cited utterance, and that most claim content words ground in that same utterance. No relation-word bank participates. | | A reflection is only ever a proposal | `stage1-loop.ts`, `proposal-store.ts`, `App.tsx` | A validated reflection becomes a `shown` proposal with per-claim confirm/decline; only confirmed chunks reach the gateway and become cards. | | Reflections never carry novel L2 prose | `stage1-loop.ts` | Reflections remain source-backed at every level. L0 is limited to one recorded user moment; L1/L2 cross-turn or draft+chat synthesis is accepted as `ai_connected`. Novel L2 language must use an attributed suggestion or map proposal. | diff --git a/src/api.test.ts b/src/api.test.ts index f393075..3334417 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -202,6 +202,7 @@ describe("typed assistant response parser", () => { expect(first[0].content).toContain("mirror it in that turn rather than asking about it"); expect(first[0].content).toContain("each [id] in the Source Bank is one sentence"); expect(first[0].content).toContain("Turn length is never by itself a reason to ask instead of mirror."); + expect(first[0].content).toContain("Negation words are not glue"); expect(first[0].content).toContain("takes precedence over the turn-shape, map-pacing, and reflection-rhythm facts"); expect(first[0].content).toContain("one recorded user moment (one user turn, which may contain several sentences and several claims)"); expect(fetchMock).toHaveBeenCalledTimes(2); diff --git a/src/api.ts b/src/api.ts index 99c815d..885234a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -217,7 +217,7 @@ QUESTION FRAMING CHECK: A question is also a form of framing. Before emitting one, inspect every substantive concept, relationship, assumption, candidate answer, and direction it contains. If the user has not stated that material and it is not established in the confirmed map, do not smuggle it into the question. At L0/L1, remove it and ask about the genuine gap using the user's concepts. At L2, if the material would be a useful contribution, move it into an explicitly attributed suggestion; otherwise remove it. Do not turn an unstated answer into a forced choice. Use direct quotation only when it makes the referent clearer, and integrate quoted wording into a natural, understandable sentence. A fragment or pronoun copied exactly can still make a question confusing; name the user's established topic plainly without adding a substantive premise. Do not force the user to unpack a list of words one by one when a synthesis or broader gap is the useful next move. For example, after separate statements about visibility and redistribution, "Why does visibility lead to redistribution?" falsely assumes a relationship. L0 can ask, "How, if at all, do visibility and redistribution belong together for you?" L2 can instead say, "AI suggestion: visibility might be a condition for redistribution rather than the same change. Does that fit, or would you frame their relationship differently?" SHARED AUTHORSHIP AND GROUNDING: -In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} +In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Negation words are not glue: use 'not', 'no', 'never', n't, or 'without' only where the userPhrase you cite contains it, and when the user negates an idea, cite the negating word inside that userPhrase so the mirror keeps the user's meaning. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} Schema: ${transport === "responses_tools" ? "For this transport, reflection and map_proposal entries describe normalized application responses only; emit them through their named tools. Text output is provider-constrained to question, aside, grounded_recap, options, suggestion, or translation." : ""} diff --git a/src/stage1-loop.ts b/src/stage1-loop.ts index 442d549..b8ddc96 100644 --- a/src/stage1-loop.ts +++ b/src/stage1-loop.ts @@ -22,7 +22,7 @@ import { CandidateStore, SourceBank } from "./store"; import { detectTurnShape } from "./turn-shape"; import { detectLatestUserLanguagePattern, type LanguageContext } from "./language-context"; import type { CandidateThought, CandidateTarget, GroundedClaim, SourceUtterance } from "./types"; -import { validateGroundedClaims, validateMirror } from "./validator"; +import { POLARITY_REPAIR_NOTE, validateGroundedClaims, validateMirror } from "./validator"; export interface ProcessTurnOptions { mapRevision: number; @@ -240,7 +240,8 @@ function createProposal(envelope: AssistantResponseEnvelope, state: Conversation } const validation = validateGroundedClaims(claims, state.bank.getAll(), config); if (!validation.ok || !claims.every((claim) => deriveClaimAttribution(claim, state.bank.getAll()) === "asserted")) { - return { rejection: { code: "grounded_recap_validation_failed", detail: "The recap must use exact, source-backed user wording." }, diagnostics: [diagnostic("validation", "rejected", "grounded_recap_validation_failed", "Grounded recap evidence pointers did not validate.")] }; + const polarityFailed = validation.claims.some((claim) => claim.checks.some((check) => check.check === "polarity" && !check.ok)); + return { rejection: { code: "grounded_recap_validation_failed", detail: `The recap must use exact, source-backed user wording.${polarityFailed ? ` ${POLARITY_REPAIR_NOTE}` : ""}` }, diagnostics: [diagnostic("validation", "rejected", "grounded_recap_validation_failed", "Grounded recap evidence pointers did not validate.")] }; } return { diagnostics: [diagnostic("validation", "accepted", "grounded_recap_valid", "Grounded recap evidence pointers validated; no map proposal was created.")] }; } diff --git a/src/validator.test.ts b/src/validator.test.ts index 414cb6a..ac106ea 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -508,41 +508,64 @@ describe("mirror validator — 2 grounding checks", () => { describe("mirror validator — polarity", () => { const span = (text: string, utterance: SourceUtterance, phrase = text) => ({ claimText: text, utteranceIds: [utterance.id], userPhrase: phrase }); + const polarityOf = (text: string, spans: ReturnType[], bank: SourceUtterance[]) => + checkOf(validateMirror({ claims: [claim(text, spans)] }, bank, defaultConfig).claims[0], "polarity")?.ok; it("rejects a mirror that adds a negation the user never said", () => { const said = u("Money is the main problem in my essay."); - for (const text of ["Money is not the main problem", "Money is never the main problem", "Money isn't the main problem"]) { + for (const text of ["Money is not the main problem", "Money is never the main problem", "Money isn't the main problem", "Money is no main problem"]) { const result = validateMirror({ claims: [claim(text, [span(text, said, "Money is the main problem")])] }, [said], defaultConfig); expect(result.ok, text).toBe(false); expect(checkOf(result.claims[0], "polarity")?.ok, text).toBe(false); - expect(result.claims[0].message).toContain("negation"); + expect(result.claims[0].message).toContain("negation words do not match"); } }); it("rejects a mirror that drops a negation inside the cited phrase", () => { const said = u("Money isn't the main problem in my essay."); - const result = validateMirror({ claims: [claim("Money is the main problem", [span("Money is the main problem", said, "Money isn't the main problem")])] }, [said], defaultConfig); - expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + expect(polarityOf("Money is the main problem", [span("Money is the main problem", said, "Money isn't the main problem")], [said])).toBe(false); }); - it("rejects dropping a negation by citing the words around it", () => { - const said = u("Structure never matters to my readers."); - const result = validateMirror({ - claims: [claim("Structure matters to my readers", [span("Structure", said), span("matters to my readers", said)])], - }, [said], defaultConfig); - expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + it("rejects swapping one negation word for another", () => { + const said = u("Money is not the main problem."); + expect(polarityOf("Money is never the main problem", [span("Money is never the main problem", said, "Money is not the main problem")], [said])).toBe(false); }); - it("keeps faithful negated and unnegated mirrors passing", () => { - const negated = u("Money doesn't matter as much as time."); - const plain = u("Time matters most."); - expect(validateMirror({ claims: [claim("money doesn't matter as much as time", [span("money doesn't matter as much as time", negated, "Money doesn't matter as much as time")])] }, [negated], defaultConfig).ok).toBe(true); - expect(validateMirror({ claims: [claim("time matters most", [span("time matters most", plain, "Time matters most")])] }, [plain], defaultConfig).ok).toBe(true); + it("treats n't, apostrophe-less and spelled-out 'not' as the same word", () => { + const said = u("Money is not the main problem, and I can’t ignore time."); + expect(polarityOf("Money isn't the main problem", [span("Money isn't the main problem", said, "Money is not the main problem")], [said])).toBe(true); + expect(polarityOf("Money isnt the main problem", [span("Money isnt the main problem", said, "Money is not the main problem")], [said])).toBe(true); + expect(polarityOf("I cannot ignore time", [span("I cannot ignore time", said, "I can’t ignore time")], [said])).toBe(true); + }); + + it("does not read a standalone letter T as a negation", () => { + const said = u("Section T: safety matters."); + expect(polarityOf("Section T safety matters", [span("Section T safety matters", said, "Section T: safety matters")], [said])).toBe(true); }); - it("does not treat a negation elsewhere in the sentence as negating the cited phrase", () => { - const said = u("Money matters, not time."); - const result = validateMirror({ claims: [claim("money matters", [span("money matters", said, "Money matters")])] }, [said], defaultConfig); + // The check compares a claim with its cited pointers, not the surrounding + // sentence, so negation elsewhere in the user's sentence never blocks a mirror. + it.each([ + ["Not only money matters, but time matters too.", "money matters"], + ["Without delay, submit the draft.", "submit the draft"], + ["No matter what, clarity matters.", "clarity matters"], + ["Money matters, not time.", "money matters"], + ["I do not want a long intro; the argument comes first.", "the argument comes first"], + ])("keeps a faithful unnegated mirror from %s passing", (sentence, phrase) => { + const said = u(sentence); + const result = validateMirror({ claims: [claim(phrase, [span(phrase, said)])] }, [said], defaultConfig); expect(checkOf(result.claims[0], "polarity")?.ok).toBe(true); + expect(result.ok).toBe(true); + }); + + it("keeps faithful negated mirrors passing", () => { + const negated = u("Money doesn't matter as much as time."); + const result = validateMirror({ claims: [claim("money doesn't matter as much as time", [span("money doesn't matter as much as time", negated, "Money doesn't matter as much as time")])] }, [negated], defaultConfig); + expect(result.ok).toBe(true); + }); + + it("does not treat Chinese text as negated", () => { + const said = u("写作自由很重要"); + expect(polarityOf("写作自由很重要", [span("写作自由很重要", said)], [said])).toBe(true); }); }); diff --git a/src/validator.ts b/src/validator.ts index 18fd53e..b0e1b35 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,8 +1,8 @@ /** * Mirror validator — the enforcement core. * - * Before any mirror reflection can be shown to the user, it must pass two - * grounding checks against the Source Bank. This is *code*, not prompting: a + * Before any mirror reflection can be shown to the user, it must pass three + * checks against the Source Bank. This is *code*, not prompting: a * reflection that fails cannot be displayed. The bounded recovery path * (see stage1-loop) decides what conversational move to make instead; code does * not interpret epistemic meaning (e.g. treating "I think" as uncertainty) — @@ -14,9 +14,9 @@ * free, but there is no unsupported-word budget. * 2. Source-span grounding — does every claim trace to a user utterance that * actually supports it? (catches new *relationships* built from real words) - * 3. Polarity — a claim is negated exactly when its cited wording is. Negators - * are function words, so checks 1 and 2 alone let a mirror add or drop a - * "not" and reverse the user's meaning while every content word matches. + * 3. Polarity — a claim uses exactly the negation words of the phrases it + * cites. Negators are function words, so checks 1 and 2 alone let a mirror + * add a "not" and reverse the user's meaning while every content word matches. * * Checks run per-claim so the caller can show/confirm chunks independently and * knows exactly which span to ask about when one fails. @@ -26,7 +26,7 @@ import type { MindmapConfig } from "./config"; import { containsWholePhrase, contentTokens, - findWholePhraseRange, + foldWidthAndQuotes, isStopword, normalize, stem, @@ -49,55 +49,60 @@ function ratio(part: number, whole: number): number { } /** - * English negation markers, as produced by `tokenize`. Contractions split at the - * apostrophe ("isn't" → "isn", "t"), so the bare "t" marks every n't form; the - * apostrophe-less spellings are listed explicitly. Chinese negation is not - * covered here: its markers are characters inside words (不是, 没有) and also - * occur in non-negative words (非常), so a token list would misfire. + * English negation words. They are stopwords, so lexical grounding lets them in + * as free glue; this list takes them back out of that exemption. It does not + * interpret meaning — it only requires that a negation word be pointed at like + * a content word. The apostrophe-less spellings of n't forms map to "not". + * Chinese negation is not covered: its markers sit inside words (不是, 没有) and + * also occur in non-negative words (非常). */ -const NEGATION_MARKERS = new Set([ - "not", "no", "never", "nor", "neither", "none", "nobody", "nothing", "nowhere", - "cannot", "without", "t", - "dont", "doesnt", "didnt", "isnt", "arent", "wasnt", "werent", "cant", "couldnt", +const NEGATION_WORDS = new Set([ + "not", "no", "never", "nor", "neither", "none", "nobody", "nothing", "nowhere", "without", +]); +const NOT_SPELLINGS = new Set([ + "cannot", "dont", "doesnt", "didnt", "isnt", "arent", "wasnt", "werent", "cant", "couldnt", "wont", "wouldnt", "shouldnt", "hasnt", "havent", "hadnt", "aint", "mustnt", ]); -function hasNegation(tokens: string[]): boolean { - return tokens.some((token) => NEGATION_MARKERS.has(token)); -} - -/** How many tokens before a cited phrase can negate it ("does not really matter"). */ -const NEGATION_LOOKBEHIND = 2; - /** - * Is this cited span negated in the user's own words? Either the phrase itself - * contains a negator, or one sits just before it in a cited utterance. The - * second case stops a mirror from citing the words on either side of a "never" - * ("X" + "matters" out of "X never matters") to drop it. + * The negation words in a text, so that "isn't", "is not" and "isnt" compare + * equal. "n't" is expanded as a literal substring before tokenizing (which + * would otherwise split "isn't" into "isn" + "t", indistinguishable from the + * letter T); no pattern matching or clause parsing is involved. */ -function spanIsNegated(span: SourceSpan, bank: Map): boolean { - if (hasNegation(tokenize(span.userPhrase))) return true; - return citedTexts(span, bank).some((source) => { - const range = findWholePhraseRange(source, span.userPhrase); - if (!range) return false; - return hasNegation(tokenize(source.slice(0, range.start)).slice(-NEGATION_LOOKBEHIND)); - }); +function negationWords(text: string): Set { + const expanded = foldWidthAndQuotes(text).toLocaleLowerCase("und").split("n't").join(" not"); + const words = new Set(); + for (const token of tokenize(expanded)) { + if (NEGATION_WORDS.has(token)) words.add(token); + else if (NOT_SPELLINGS.has(token)) words.add("not"); + } + return words; } /** - * Polarity — the claim is negated exactly when its cited wording is. Content-word - * matching can't see this: "Money is not the main problem" uses only the content - * words of "Money is the main problem". + * Polarity — a claim uses exactly the negation words of the user phrases it + * cites. Content-word matching can't see this: "Money is not the main problem" + * uses only the content words of "Money is the main problem". Like lexical + * grounding, this compares the claim with its cited pointers, not with the + * surrounding sentence, so it catches an added negation and one dropped from + * inside a cited phrase. It does not catch a negation dropped by citing the + * words on either side of it; the user's confirmation of each chunk covers that. */ -function checkPolarity( - claim: GroundedClaim, - bank: Map, -): MirrorCheckResult { - const claimNegated = hasNegation(tokenize(claim.text)); - const citedNegated = claim.sourceSpans.some((span) => spanIsNegated(span, bank)); - return { check: "polarity", ok: claimNegated === citedNegated, score: claimNegated === citedNegated ? 1 : 0, threshold: 1 }; +function checkPolarity(claim: GroundedClaim): MirrorCheckResult { + const inClaim = negationWords(claim.text); + const cited = negationWords([ + ...claim.sourceSpans.map((span) => span.userPhrase), + ...(claim.relationSpan ? [claim.relationSpan.text] : []), + ].join(" \n ")); + const ok = inClaim.size === cited.size && [...inClaim].every((word) => cited.has(word)); + return { check: "polarity", ok, score: ok ? 1 : 0, threshold: 1 }; } +/** Repair hint for a polarity failure; stage1-loop forwards it to the repair call. */ +export const POLARITY_REPAIR_NOTE = + "The claim's negation words do not match the user phrases it cites: use 'not', 'no', 'never' or n't only where the cited userPhrase has it, and include the negating word in the userPhrase whenever it applies."; + /** Look up the text of cited utterances; missing ids contribute nothing. */ function citedTexts(span: SourceSpan, bank: Map): string[] { return span.utteranceIds @@ -285,7 +290,7 @@ function validateClaim( const lexical = checkLexicalGrounding(claim, citedPhraseStemSet(claim)); const grounding = checkSpanGrounding(claim, bank, cfg.mirror.spanGroundingMin); - const polarity = checkPolarity(claim, bank); + const polarity = checkPolarity(claim); const checks = [lexical.result, grounding.result, polarity]; const ok = checks.every((c) => c.ok); @@ -296,9 +301,7 @@ function validateClaim( const failed = checks.filter((c) => !c.ok).map((c) => c.check); // The polarity note goes to the repair call verbatim, so it names the fix. - const polarityNote = polarity.ok - ? "" - : " The claim's negation does not match the cited wording: keep a 'not'/'never'/n't exactly where the user said it, and never add one."; + const polarityNote = polarity.ok ? "" : ` ${POLARITY_REPAIR_NOTE}`; const message = ok ? "Reflection is grounded in the user's words." : `Reflection not grounded in the user's words — failed checks: ${failed.join(", ")}.${polarityNote}`; From 3fa1ab14782bd0212706d11ec181706a97073cdc Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:45:10 -0400 Subject: [PATCH 5/6] fix(validator): keep polarity honest about spelling and broad pointers Spelling equivalence (isn't = is not) holds only inside the polarity check; lexical grounding still requires the user's own spelling, exactly as on main. Tests now assert the full validator result, pin that end-to-end behavior, and the prompt asks the model to keep the user's spelling. A pointer wider than the claim that carries a negation the claim leaves out ("Not only money matters" cited for "money matters") is rejected by the polarity check. The prompt and repair note now ask for the narrower pointer, and a stage1-loop test shows that rejection repairing on call 2 without reaching the forced question. Another stage1-loop test pins the polarity note in grounded-recap rejections. Docs row clarified: equivalence is polarity-only, relationSpan participates, broad-pointer behavior described. Co-Authored-By: Claude Opus 5.5 --- docs/airtightness-report.md | 2 +- src/api.test.ts | 2 ++ src/api.ts | 2 +- src/stage1-loop.test.ts | 42 +++++++++++++++++++++++++++++++++++++ src/validator.test.ts | 37 ++++++++++++++++++++++++++------ src/validator.ts | 10 ++++++++- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/airtightness-report.md b/docs/airtightness-report.md index 610e61d..7c20770 100644 --- a/docs/airtightness-report.md +++ b/docs/airtightness-report.md @@ -37,7 +37,7 @@ action. No keyword/regex bank interprets user intent in the enforcement path. | Constraint | File(s) | Mechanism | | --- | --- | --- | | Reflections and recaps use the user's words | `validator.ts`, `stage1-loop.ts` | Every distinct substantive content word in each claim must be supported by cited eligible evidence; ordinary function-word glue is ignored. Visible text is reconstructed from validated claims rather than trusted from a model-authored wrapper. | -| Reflections and recaps keep the user's negation | `validator.ts`, `stage1-loop.ts` | Negation words (`not`/`no`/`never`/n't/`without`…) are taken out of the free-glue exemption: a claim must use exactly the negation words of the `userPhrase`s it cites (n't, `not` and apostrophe-less spellings compare equal). This is pointer verification, not meaning detection: no clause parsing or idiom list, so negation elsewhere in the user's sentence never blocks a mirror. Known gap: a negation dropped by citing the words on either side of it is not caught; the user's per-chunk confirmation covers it. English only. | +| Reflections and recaps keep the user's negation | `validator.ts`, `stage1-loop.ts` | Negation words (`not`/`no`/`never`/n't/`without`…) are taken out of the free-glue exemption: a claim must use exactly the negation words of the `userPhrase`s and `relationSpan` it cites. Inside this check n't, `not` and apostrophe-less spellings compare equal; lexical grounding still requires the user's own spelling, so end to end the mirror keeps it. This is pointer verification, not meaning detection: no clause parsing or idiom list, so negation elsewhere in the user's sentence never blocks a mirror, but a pointer wider than the claim that carries a negation the claim omits is rejected, and the repair note asks for the narrower pointer. Known gap: a negation dropped by citing the words on either side of it is not caught; the user's per-chunk confirmation covers it. English only. | | Relationships/hierarchy are stated by the user in one breath | `validator.ts` | Each cited span must ground `>= 0.75` of its phrase within a *single* utterance. For hierarchy/connection claims, the model declares the literal `relationSpan` connective; code verifies it is a verbatim substring of both the claim text and one cited utterance, and that most claim content words ground in that same utterance. No relation-word bank participates. | | A reflection is only ever a proposal | `stage1-loop.ts`, `proposal-store.ts`, `App.tsx` | A validated reflection becomes a `shown` proposal with per-claim confirm/decline; only confirmed chunks reach the gateway and become cards. | | Reflections never carry novel L2 prose | `stage1-loop.ts` | Reflections remain source-backed at every level. L0 is limited to one recorded user moment; L1/L2 cross-turn or draft+chat synthesis is accepted as `ai_connected`. Novel L2 language must use an attributed suggestion or map proposal. | diff --git a/src/api.test.ts b/src/api.test.ts index 3334417..3121c7e 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -203,6 +203,8 @@ describe("typed assistant response parser", () => { expect(first[0].content).toContain("each [id] in the Source Bank is one sentence"); expect(first[0].content).toContain("Turn length is never by itself a reason to ask instead of mirror."); expect(first[0].content).toContain("Negation words are not glue"); + expect(first[0].content).toContain("spelled as the user wrote it"); + expect(first[0].content).toContain("cite only the words the claim reproduces"); expect(first[0].content).toContain("takes precedence over the turn-shape, map-pacing, and reflection-rhythm facts"); expect(first[0].content).toContain("one recorded user moment (one user turn, which may contain several sentences and several claims)"); expect(fetchMock).toHaveBeenCalledTimes(2); diff --git a/src/api.ts b/src/api.ts index 885234a..0e643f7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -217,7 +217,7 @@ QUESTION FRAMING CHECK: A question is also a form of framing. Before emitting one, inspect every substantive concept, relationship, assumption, candidate answer, and direction it contains. If the user has not stated that material and it is not established in the confirmed map, do not smuggle it into the question. At L0/L1, remove it and ask about the genuine gap using the user's concepts. At L2, if the material would be a useful contribution, move it into an explicitly attributed suggestion; otherwise remove it. Do not turn an unstated answer into a forced choice. Use direct quotation only when it makes the referent clearer, and integrate quoted wording into a natural, understandable sentence. A fragment or pronoun copied exactly can still make a question confusing; name the user's established topic plainly without adding a substantive premise. Do not force the user to unpack a list of words one by one when a synthesis or broader gap is the useful next move. For example, after separate statements about visibility and redistribution, "Why does visibility lead to redistribution?" falsely assumes a relationship. L0 can ask, "How, if at all, do visibility and redistribution belong together for you?" L2 can instead say, "AI suggestion: visibility might be a condition for redistribution rather than the same change. Does that fit, or would you frame their relationship differently?" SHARED AUTHORSHIP AND GROUNDING: -In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Negation words are not glue: use 'not', 'no', 'never', n't, or 'without' only where the userPhrase you cite contains it, and when the user negates an idea, cite the negating word inside that userPhrase so the mirror keeps the user's meaning. Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} +In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Negation words are not glue: a claim must use exactly the negation words ('not', 'no', 'never', n't, 'without') of the userPhrases it cites. When the user negates an idea, cite the negating word inside that userPhrase and keep it, spelled as the user wrote it ('can't' stays 'can't'). When the claim does not negate the idea, cite only the words the claim reproduces, so the userPhrase carries no negation the claim leaves out (to mirror 'money matters' from 'Not only money matters', cite 'money matters'). Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} Schema: ${transport === "responses_tools" ? "For this transport, reflection and map_proposal entries describe normalized application responses only; emit them through their named tools. Text output is provider-constrained to question, aside, grounded_recap, options, suggestion, or translation." : ""} diff --git a/src/stage1-loop.test.ts b/src/stage1-loop.test.ts index 1b82012..75eae4e 100644 --- a/src/stage1-loop.test.ts +++ b/src/stage1-loop.test.ts @@ -5,6 +5,7 @@ import { buildContext, createConversationState, deriveClaimAttribution, MAX_MODE import { cardRef, resetIdCounter } from "./store"; import { ASSISTANCE_CONTRACTS } from "./assistance-contract"; import { ModelResponseValidationError } from "./assistant-response"; +import { POLARITY_REPAIR_NOTE } from "./validator"; beforeEach(() => resetIdCounter()); @@ -154,6 +155,47 @@ describe("typed Stage 1 controller", () => { expect(result.response).toMatchObject({ kind: "question" }); }); + it("repairs a polarity rejection from a broad pointer without reaching the forced question", async () => { + const state = createConversationState(); + const store = new ThoughtUnitStore(); + const model = vi.fn(async (context: import("./llm-contract").LLMContext, rejection?: import("./assistant-response").StructuredRejection) => { + const sourceId = context.bank[0]!.id; + const id = rejection ? "c2" : "c1"; + // First attempt cites a pointer wider than the claim, carrying a "not" the claim leaves out. + const userPhrase = rejection ? "money matters" : "Not only money matters"; + return { + response: { kind: "reflection" as const, text: "money matters", reflection: { claims: [{ + id, text: "money matters", candidateId: id, target: "idea" as const, + sourceSpans: [{ claimText: "money matters", userPhrase, utteranceIds: [sourceId] }], + }] } }, + advisory: { candidateUpserts: [{ id, target: "idea" as const, gist: "money", addEvidenceIds: [sourceId], status: "active" as const }] }, + }; + }); + + const result = await processTurn(state, "Not only money matters, but time matters too.", model, defaultConfig, store.toLLMContext(), { mapRevision: 0, requireConnectionLabel: true, store }); + expect(model).toHaveBeenCalledTimes(2); + expect(model.mock.calls[1]?.[1]).toMatchObject({ code: "reflection_validation_failed", reflectionRecovery: { stage: "informed_repair" } }); + expect(model.mock.calls[1]?.[1]?.detail).toContain(POLARITY_REPAIR_NOTE); + expect(result.response).toMatchObject({ kind: "reflection", text: "money matters" }); + }); + + it("forwards the polarity repair note when a grounded recap's negation does not match its pointer", async () => { + const state = createConversationState(); + const store = new ThoughtUnitStore(); + const model = vi.fn(async (context: import("./llm-contract").LLMContext, rejection?: import("./assistant-response").StructuredRejection) => { + if (rejection) return { response: { kind: "question" as const, text: "What matters most here?" } }; + const sourceId = context.bank[0]!.id; + return { response: { kind: "grounded_recap" as const, text: "recap", recap: { claims: [{ + id: "r1", text: "money is the main problem", target: "idea" as const, + sourceSpans: [{ claimText: "money is the main problem", userPhrase: "Money is not the main problem", utteranceIds: [sourceId] }], + }] } } }; + }); + + await processTurn(state, "Money is not the main problem.", model, defaultConfig, store.toLLMContext(), { mapRevision: 0, requireConnectionLabel: true, store }); + expect(model.mock.calls[1]?.[1]).toMatchObject({ code: "grounded_recap_validation_failed" }); + expect(model.mock.calls[1]?.[1]?.detail).toContain(POLARITY_REPAIR_NOTE); + }); + it("repairs a question whose draft anchor is not an exact current-draft substring", async () => { const state = createConversationState(); state.draft = "Those classes probably did matter."; diff --git a/src/validator.test.ts b/src/validator.test.ts index ac106ea..2a5da5f 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -523,24 +523,47 @@ describe("mirror validator — polarity", () => { it("rejects a mirror that drops a negation inside the cited phrase", () => { const said = u("Money isn't the main problem in my essay."); - expect(polarityOf("Money is the main problem", [span("Money is the main problem", said, "Money isn't the main problem")], [said])).toBe(false); + const result = validateMirror({ claims: [claim("Money is the main problem", [span("Money is the main problem", said, "Money isn't the main problem")])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + expect(result.ok).toBe(false); }); it("rejects swapping one negation word for another", () => { - const said = u("Money is not the main problem."); - expect(polarityOf("Money is never the main problem", [span("Money is never the main problem", said, "Money is not the main problem")], [said])).toBe(false); + const said = u("Money is not the main problem, and it is never easy."); + const result = validateMirror({ claims: [claim("Money is never the main problem", [span("Money is never the main problem", said, "Money is not the main problem")])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(false); + expect(result.ok).toBe(false); }); - it("treats n't, apostrophe-less and spelled-out 'not' as the same word", () => { + it("compares n't, apostrophe-less and spelled-out 'not' as the same negation word", () => { const said = u("Money is not the main problem, and I can’t ignore time."); expect(polarityOf("Money isn't the main problem", [span("Money isn't the main problem", said, "Money is not the main problem")], [said])).toBe(true); expect(polarityOf("Money isnt the main problem", [span("Money isnt the main problem", said, "Money is not the main problem")], [said])).toBe(true); expect(polarityOf("I cannot ignore time", [span("I cannot ignore time", said, "I can’t ignore time")], [said])).toBe(true); }); + // Spelling equivalence is polarity-only: lexical grounding still requires the + // user's own spelling, exactly as on main. The prompt asks the model to keep it. + it("still requires the user's own negation spelling end to end", () => { + const said = u("Money is not the main problem, and I can’t ignore time."); + expect(validateMirror({ claims: [claim("Money isn't the main problem", [span("Money isn't the main problem", said, "Money is not the main problem")])] }, [said], defaultConfig).ok).toBe(false); + expect(validateMirror({ claims: [claim("Money is not the main problem", [span("Money is not the main problem", said)])] }, [said], defaultConfig).ok).toBe(true); + expect(validateMirror({ claims: [claim("I can't ignore time", [span("I can't ignore time", said, "I can’t ignore time")])] }, [said], defaultConfig).ok).toBe(true); + }); + + it("rejects a pointer that carries a negation the claim leaves out, and accepts the narrower pointer", () => { + const said = u("Not only money matters, but time matters too."); + const broad = validateMirror({ claims: [claim("money matters", [span("money matters", said, "Not only money matters")])] }, [said], defaultConfig); + expect(checkOf(broad.claims[0], "polarity")?.ok).toBe(false); + expect(broad.claims[0].message).toContain("cite only the words the claim reproduces"); + expect(validateMirror({ claims: [claim("money matters", [span("money matters", said)])] }, [said], defaultConfig).ok).toBe(true); + }); + it("does not read a standalone letter T as a negation", () => { const said = u("Section T: safety matters."); - expect(polarityOf("Section T safety matters", [span("Section T safety matters", said, "Section T: safety matters")], [said])).toBe(true); + const result = validateMirror({ claims: [claim("Section T safety matters", [span("Section T safety matters", said, "Section T: safety matters")])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(true); + expect(result.ok).toBe(true); }); // The check compares a claim with its cited pointers, not the surrounding @@ -566,6 +589,8 @@ describe("mirror validator — polarity", () => { it("does not treat Chinese text as negated", () => { const said = u("写作自由很重要"); - expect(polarityOf("写作自由很重要", [span("写作自由很重要", said)], [said])).toBe(true); + const result = validateMirror({ claims: [claim("写作自由很重要", [span("写作自由很重要", said)])] }, [said], defaultConfig); + expect(checkOf(result.claims[0], "polarity")?.ok).toBe(true); + expect(result.ok).toBe(true); }); }); diff --git a/src/validator.ts b/src/validator.ts index b0e1b35..9023a1d 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -88,6 +88,14 @@ function negationWords(text: string): Set { * surrounding sentence, so it catches an added negation and one dropped from * inside a cited phrase. It does not catch a negation dropped by citing the * words on either side of it; the user's confirmation of each chunk covers that. + * + * Two consequences of comparing pointers: + * - Spelling equivalence ("isn't" = "is not") holds only in this check. Lexical + * grounding still requires the user's own spelling, as it does for every word, + * so the prompt asks the model to keep it. + * - A pointer wider than the claim that carries a negation the claim leaves out + * ("Not only money matters" cited for "money matters") is rejected; the repair + * note asks for the narrower pointer. */ function checkPolarity(claim: GroundedClaim): MirrorCheckResult { const inClaim = negationWords(claim.text); @@ -101,7 +109,7 @@ function checkPolarity(claim: GroundedClaim): MirrorCheckResult { /** Repair hint for a polarity failure; stage1-loop forwards it to the repair call. */ export const POLARITY_REPAIR_NOTE = - "The claim's negation words do not match the user phrases it cites: use 'not', 'no', 'never' or n't only where the cited userPhrase has it, and include the negating word in the userPhrase whenever it applies."; + "The claim's negation words do not match the user phrases it cites. If the user negated the idea, keep their negating word, spelled as they wrote it, in both the claim and its userPhrase; if the claim does not negate it, cite only the words the claim reproduces so the userPhrase holds no 'not', 'no', 'never', n't or 'without'."; /** Look up the text of cited utterances; missing ids contribute nothing. */ function citedTexts(span: SourceSpan, bank: Map): string[] { From b434f2d44adb31a8efbc63563bb568f1280869b3 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:49:45 -0400 Subject: [PATCH 6/6] fix(prompt): name relationSpan in the negation-words rule Aligns the prompt with the validator, which already counts relationSpan negation, and pins that case in a focused test. Co-Authored-By: Claude Opus 5.5 --- src/api.test.ts | 1 + src/api.ts | 2 +- src/validator.test.ts | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/api.test.ts b/src/api.test.ts index 3121c7e..aead4c1 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -203,6 +203,7 @@ describe("typed assistant response parser", () => { expect(first[0].content).toContain("each [id] in the Source Bank is one sentence"); expect(first[0].content).toContain("Turn length is never by itself a reason to ask instead of mirror."); expect(first[0].content).toContain("Negation words are not glue"); + expect(first[0].content).toContain("of the userPhrases and relationSpan it cites"); expect(first[0].content).toContain("spelled as the user wrote it"); expect(first[0].content).toContain("cite only the words the claim reproduces"); expect(first[0].content).toContain("takes precedence over the turn-shape, map-pacing, and reflection-rhythm facts"); diff --git a/src/api.ts b/src/api.ts index 0e643f7..fb40689 100644 --- a/src/api.ts +++ b/src/api.ts @@ -217,7 +217,7 @@ QUESTION FRAMING CHECK: A question is also a form of framing. Before emitting one, inspect every substantive concept, relationship, assumption, candidate answer, and direction it contains. If the user has not stated that material and it is not established in the confirmed map, do not smuggle it into the question. At L0/L1, remove it and ask about the genuine gap using the user's concepts. At L2, if the material would be a useful contribution, move it into an explicitly attributed suggestion; otherwise remove it. Do not turn an unstated answer into a forced choice. Use direct quotation only when it makes the referent clearer, and integrate quoted wording into a natural, understandable sentence. A fragment or pronoun copied exactly can still make a question confusing; name the user's established topic plainly without adding a substantive premise. Do not force the user to unpack a list of words one by one when a synthesis or broader gap is the useful next move. For example, after separate statements about visibility and redistribution, "Why does visibility lead to redistribution?" falsely assumes a relationship. L0 can ask, "How, if at all, do visibility and redistribution belong together for you?" L2 can instead say, "AI suggestion: visibility might be a condition for redistribution rather than the same change. Does that fit, or would you frame their relationship differently?" SHARED AUTHORSHIP AND GROUNDING: -In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Negation words are not glue: a claim must use exactly the negation words ('not', 'no', 'never', n't, 'without') of the userPhrases it cites. When the user negates an idea, cite the negating word inside that userPhrase and keep it, spelled as the user wrote it ('can't' stays 'can't'). When the claim does not negate the idea, cite only the words the claim reproduces, so the userPhrase carries no negation the claim leaves out (to mirror 'money matters' from 'Not only money matters', cite 'money matters'). Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} +In non-directive and grounded-options modes, never invent user ideas or relationships. Questions may scaffold reflection but must not embed an answer. When the user has already named a broad task or focus, acknowledge it briefly and move to one concrete next step; do not rephrase that goal as though it were unanswered. Treat the full draft as background context and a user selection as explicit focus. Use the user's request, recent dialogue, and draft to judge the most useful next move. When passage choice is materially ambiguous, invite the user to choose. Use draft anchors selectively, and distinguish model-chosen anchors from user-selected focus. Include an anchor only when it is useful and copied as one exact contiguous substring from DRAFT; otherwise omit it. Evidence is an internal traceability requirement: find supporting wording in the source bank yourself instead of repeatedly asking the user to locate it. Ask for wording only when the user must genuinely decide substance. Preserve authored passages and quoted evidence in their original language. The interface display locale is never an instruction to translate or change reply language. Normally reply in the latest user turn's language pattern; a mixed-language turn may receive a natural mixed-language reply. Honor a direct user request for another response language, but never silently translate quoted evidence. When the pattern is unknown, follow the original conversation naturally rather than guessing. When the user explicitly instructs you to nest one referenced card in another, propose that nesting instead of asking them to restate a semantic relationship; cite the complete current-turn instruction containing both card references as relationEvidence. This explicit intent authorizes only the proposed nesting and still requires confirmation. Every reflection and grounded recap at every assistance level must be strictly user-word-faithful: reuse only content words from the exact original-language userPhrase evidence it cites, though ordinary English function words and the closed Chinese particle class may serve as glue. Negation words are not glue: a claim must use exactly the negation words ('not', 'no', 'never', n't, 'without') of the userPhrases and relationSpan it cites. When the user negates an idea, cite the negating word inside that userPhrase and keep it, spelled as the user wrote it ('can't' stays 'can't'). When the claim does not negate the idea, cite only the words the claim reproduces, so the userPhrase carries no negation the claim leaves out (to mirror 'money matters' from 'Not only money matters', cite 'money matters'). Never translate substantive evidence inside a mirror. Use a grounded recap when conversational consolidation is useful but map capture is not the current move; reserve a reflection for claims the user will be invited to confirm, revise, or decline for possible map placement. A grounded recap is conversational synthesis only: it is visibly labeled as a recap, creates no proposal, and must not nominate candidates merely to make itself capturable. Synthesize the cited wording in your own connective prose — a recap draws its content words from the user's evidence but frames and orders them into a fresh sentence; never repeat a single user statement back verbatim, which reads as a parrot and adds nothing. At L0, a grounded recap may restate only the current user turn, and a reflection may draw from only one recorded user moment (one user turn, which may contain several sentences and several claims); so at L0 use a recap only when that turn holds several distinct points worth drawing together, and when the user's turn is a single brief or tentative thought, prefer one focusing question over restating it. At L1 and L2, a recap or reflection may bring together eligible user wording across turns; code records the resulting selection as AI-connected even though the words remain the user's. A recap may also include current draft wording when chat wording anchors the juxtaposition. This selection is an AI conversational move, but every substantive word and any asserted relationship must remain supported by the cited user evidence. Each reflection claim's candidateId must name an existing active or parked candidate shown in context or a valid candidateUpsert with the same id in this envelope. When a new grounded reflection claim has no existing candidate, include that same-id upsert with its eligible evidence ids; never invent a dangling candidateId or reuse an ignored or promoted candidate. At L1 and L2, you may cite an exact draft-labelled passage to juxtapose it with chat wording; the connection is AI-selected but the words remain the user's. At L0, do not cite draft evidence. Never invent a relationship between separate chat and draft passages; use a suggestion at L2 for novel interpretation. Put any novel L2 language in an explicitly AI-attributed suggestion or map proposal, never a reflection or grounded recap. Reflections, grounded recaps, and asserted map proposals must carry source pointers, and hierarchy or connection claims need a relation pointer from the same user utterance. For a large or abstract turn that does not state its structure explicitly, when no grounded mirror of its main points is yet possible, favor one focusing question over arbitrarily collapsing it into one claim or presenting a proposal. This is conversational judgment, not a fixed response rule. Candidate age is a fact, never a command to recall. Recall only when it helps the current conversation, only from active or parked candidates, and only as a question or aside carrying an exact user phrase in a recall annotation. Never recall ignored or promoted candidates. Do not echo unchanged candidates in advisory bookkeeping.${repairNote} Schema: ${transport === "responses_tools" ? "For this transport, reflection and map_proposal entries describe normalized application responses only; emit them through their named tools. Text output is provider-constrained to question, aside, grounded_recap, options, suggestion, or translation." : ""} diff --git a/src/validator.test.ts b/src/validator.test.ts index 2a5da5f..025725e 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -587,6 +587,15 @@ describe("mirror validator — polarity", () => { expect(result.ok).toBe(true); }); + it("counts a negation carried only by the relationSpan", () => { + const said = u("Speed does not support quality."); + const spans = [span("speed", said), span("quality", said)]; + const negated = validateMirror({ claims: [claim("speed does not support quality", spans, "connection", { utteranceId: said.id, text: "does not support" })] }, [said], defaultConfig); + expect(checkOf(negated.claims[0], "polarity")?.ok).toBe(true); + const dropped = validateMirror({ claims: [claim("speed does support quality", spans, "connection", { utteranceId: said.id, text: "does not support" })] }, [said], defaultConfig); + expect(checkOf(dropped.claims[0], "polarity")?.ok).toBe(false); + }); + it("does not treat Chinese text as negated", () => { const said = u("写作自由很重要"); const result = validateMirror({ claims: [claim("写作自由很重要", [span("写作自由很重要", said)])] }, [said], defaultConfig);