diff --git a/docs/airtightness-report.md b/docs/airtightness-report.md index ae4be2e..7c20770 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 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 c5b5d37..aead4c1 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -196,7 +196,18 @@ 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(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"); + expect(first[0].content).toContain("one recorded user moment (one user turn, which may contain several sentences and several claims)"); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -237,6 +248,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..fb40689 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, 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. @@ -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. 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/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/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/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..025725e 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -505,3 +505,101 @@ 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 }); + 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", "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 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(result.ok).toBe(false); + }); + + it("rejects swapping one negation word for another", () => { + 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("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."); + 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 + // 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("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); + 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 8601b83..9023a1d 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,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 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. @@ -23,6 +26,7 @@ import type { MindmapConfig } from "./config"; import { containsWholePhrase, contentTokens, + foldWidthAndQuotes, isStopword, normalize, stem, @@ -44,6 +48,69 @@ function ratio(part: number, whole: number): number { return whole === 0 ? 0 : part / whole; } +/** + * 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_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", +]); + +/** + * 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 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 — 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. + * + * 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); + 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. 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[] { return span.utteranceIds @@ -231,7 +298,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); + + 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 +308,11 @@ 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 ? "" : ` ${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(", ")}.`; + : `Reflection not grounded in the user's words — failed checks: ${failed.join(", ")}.${polarityNote}`; return { claimId: claim.id, ok, checks, weakestSpan, ungroundedContentWords: lexical.ungroundedContentWords, message }; }