diff --git a/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts b/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts index ee016c6047..1f712fca71 100755 --- a/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts +++ b/LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts @@ -211,7 +211,8 @@ TYPE GUIDANCE: - Emit ONE memory item per actor you want to change, with op:"set" and the complete entries array. Don't emit an item for an actor whose file needs no change. - Each entry keeps a prefix: NAME: / ROLE: / RELATION: / PREFERENCE: / RULE: — followed by the fact, then a provenance tag: ~explicit ({{PRINCIPAL_NAME}} stated it), ~deduced (logical inference from what he stated), or ~inferred (a pattern you noticed). Untagged is read as ~explicit. Example: "PREFERENCE: prefers terse direct responses ~explicit". - DECLARATIVE FACTS, NOT DIRECTIVES. Write "PREFERENCE: prefers terse responses ~explicit", NOT "RULE: Always be terse" — a directive gets re-read later as a command. State what is true, not what to do (RULE: is for genuine standing rules {{PRINCIPAL_NAME}} set, phrased as facts about his rules). - - SUPERSEDE, don't stack. If a new fact contradicts an existing entry ("works at A" → "works at B"), DROP the old entry and write the new one. Never keep both. + - SUPERSEDE, don't stack — but only on evidence. If {{PRINCIPAL_NAME}} stated the new fact in this conversation ("works at A" → he says "works at B"), DROP the old entry and write the new one. Never keep both. + - NEVER RESOLVE A CONFLICT YOU CANNOT SOURCE. If two existing entries disagree and nothing in this conversation settles which is true, you may NOT pick a winner. Picking is a guess, and a guess written as ~explicit is later read as "{{PRINCIPAL_NAME}} said this". Instead: keep the most recent entry, tag it ~inferred, and say so in your rationale so it can be confirmed. A wrong value at high confidence is far more damaging than an open question. - MERGE duplicates. Three entries saying the same thing collapse to one. - CAP: 48 entries × 256 chars per file. When the current list is ≥39 entries (≥80% full), CONSOLIDATE FIRST — merge related entries and drop the least useful/most stale — BEFORE adding anything new. Your returned list MUST be ≤48 or the write is rejected. - Keep the entries that still reduce future steering; drop the ones that have gone stale. diff --git a/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.conflict.test.ts b/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.conflict.test.ts new file mode 100644 index 0000000000..77e91b67ec --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.conflict.test.ts @@ -0,0 +1,105 @@ +/** + * Conflict-rule tests. + * + * The regression that motivated this: a memory file held three incompatible answers + * to one question (which days a recurring activity happens). A curation write + * collapsed them by picking one, and the pick was wrong and marked ~explicit, which + * later reads as "the principal stated this". The rule must catch that shape and must + * NOT catch ordinary supersession, where the principal simply states a new value. + * + * Fixtures are deliberately neutral: this file ships publicly, so it carries no real + * names, relationships, or personal measurements. + */ +import { expect, test, describe } from "bun:test"; +import { detectValueConflicts } from "./MemoryWriter.ts"; + +describe("detectValueConflicts", () => { + test("catches the motivating regression: prior disagreed, reviewer picked", () => { + const prior = [ + "PREFERENCE: attends class on Monday, Thursday, and Friday ~explicit", + "PREFERENCE: attends class on Monday and Friday evenings ~explicit", + ]; + const next = [ + "PREFERENCE: attends class Monday and Friday evenings, working toward the next grade ~explicit", + ]; + const conflicts = detectValueConflicts(prior, next); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].kind).toBe("weekday"); + expect(conflicts[0].values.incoming).toEqual(["friday", "monday"]); + }); + + test("ignores ordinary supersession — one prior value, new value stated", () => { + const prior = ["PREFERENCE: target load for the main lift is 100kg ~explicit"]; + const next = ["PREFERENCE: target load for the main lift is 105kg ~explicit"]; + expect(detectValueConflicts(prior, next)).toHaveLength(0); + }); + + test("ignores unchanged entries — the reviewer declined to resolve", () => { + const prior = [ + "PREFERENCE: attends class on Monday and Friday ~explicit", + "PREFERENCE: attends class on Monday, Thursday and Friday ~explicit", + ]; + expect(detectValueConflicts(prior, prior)).toHaveLength(0); + }); + + test("ignores entries with no closed-vocabulary value", () => { + const prior = ["ROLE: works at Acme ~explicit", "ROLE: works at Globex ~explicit"]; + const next = ["ROLE: works at Initech ~explicit"]; + expect(detectValueConflicts(prior, next)).toHaveLength(0); + }); + + test("a single prior value is supersession, not a guess", () => { + const prior = [ + "PREFERENCE: attends class on Monday and Friday ~explicit", + "PREFERENCE: attends class on Monday, Thursday and Friday ~explicit", + "ROLE: office days are Wednesday and Thursday ~explicit", + ]; + const next = ["ROLE: office days are Tuesday and Thursday ~explicit"]; + expect(detectValueConflicts(prior, next)).toHaveLength(0); + }); + + test("catches ambiguous quantities too", () => { + const prior = [ + "PREFERENCE: daily supplement dose is 40g ~explicit", + "PREFERENCE: daily supplement dose is 60g ~explicit", + ]; + const next = ["PREFERENCE: daily supplement dose is 40g ~explicit"]; + const conflicts = detectValueConflicts(prior, next); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].kind).toBe("quantity:g"); + }); + + test("does not compare across units — 7h is not a rival of 40g", () => { + const prior = [ + "PREFERENCE: daily supplement dose is 40g and minimum rest is 7h ~explicit", + "PREFERENCE: daily supplement dose is 60g and minimum rest is 7h ~explicit", + ]; + const next = ["PREFERENCE: daily supplement dose is 40g and minimum rest is 7h ~explicit"]; + const kinds = detectValueConflicts(prior, next).map((c) => c.kind); + expect(kinds).toContain("quantity:g"); + expect(kinds).not.toContain("quantity:h"); + }); + + // KNOWN FALSE POSITIVE, recorded rather than wished away. + // + // Entries can share two content words while being about different things — a + // Saturday planning session and a Monday errand are not rival answers to one + // question. Token overlap cannot separate them; only meaning can, and asking a + // model for meaning is the defect this rule exists to remove. + // + // Measured by replaying a real 186 -> 45 curation: 4 flags, 2 true (including the + // motivating regression, plus a genuine review-day conflict that had gone + // unnoticed), 2 false. The cost of a false positive is bounded — the entry + // survives verbatim and only its provenance drops to ~inferred, which the next + // explicit statement restores. The cost of a false negative is a wrong value + // carrying "the principal stated this" indefinitely. That asymmetry is why this is + // tuned to over-flag. + test("known false positive: entries sharing two words but not a topic", () => { + const prior = [ + "RELATION: partner recurring Saturday planning session ~explicit", + "RELATION: partner planning appointments affecting Monday ~explicit", + ]; + const next = ["RELATION: partner recurring Saturday planning session at home ~explicit"]; + expect(detectValueConflicts(prior, next)).toHaveLength(1); + }); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.ts b/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.ts index 82de10455e..cd05d2b2cd 100755 --- a/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.ts +++ b/LifeOS/install/LIFEOS/TOOLS/MemoryWriter.ts @@ -85,6 +85,19 @@ export interface SetEntriesOk { new_count: number; evictions: string[]; additions: string[]; + /** Entries whose provenance was forced down to ~inferred by the conflict rule. */ + value_conflicts: ValueConflict[]; +} + +export interface ValueConflict { + /** The incoming entry, as the reviewer wrote it. */ + entry: string; + /** Prior entries that asserted a different value on the same topic. */ + prior: string[]; + /** Which closed vocabulary disagreed — "weekday" | "time" | "quantity". */ + kind: string; + /** The differing values, for the reader. */ + values: { incoming: string[]; prior: string[] }; } export interface SetEntriesErrAtCap { @@ -440,6 +453,152 @@ function atomicWrite(filePath: string, content: string): true | SetEntriesErrIO } } +// ── Conflict rule ── +// +// The reviewer is told to SUPERSEDE rather than stack: when a new fact contradicts +// an old one, drop the old. That is right when the principal has just stated the new +// fact, and wrong when two stale entries merely disagree and the reviewer picks a +// winner — the pick is a guess, and it lands wearing ~explicit, which reads later as +// "the principal said this". That is how a wrong training schedule entered memory at +// full confidence while the correct answer was never asked for. +// +// Detection here is deterministic and deliberately narrow. It does NOT ask a model +// whether two statements conflict — that judgement is the thing being removed. It +// compares CLOSED, ENUMERABLE vocabularies only: +// +// weekday — the seven day names +// time — HH:MM clock values +// quantity — a number carrying a unit from a fixed list (kg, g, kcal, %, h, £) +// +// Two entries conflict when they share a topic anchor (a non-stopword token present +// in both, itself not a value token) and assert DIFFERENT value sets of the same +// class. Anything outside those vocabularies is not detected, by design: a broader +// rule would have to infer, which is the defect, not the fix. +// +// Enforcement is proportionate. A detected conflict does not reject the write — that +// would freeze curation on a heuristic. It forces the entry's provenance down to +// ~inferred and records the conflict, so the claim keeps circulating without false +// authority and the disagreement can be settled by the principal. + +const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; +const UNITS = ["kg", "g", "kcal", "%", "h", "hrs", "£"]; +const MIN_SHARED_ANCHORS = 2; + +const STOPWORDS = new Set([ + "the", "a", "an", "and", "or", "but", "is", "are", "was", "were", "be", "been", + "to", "of", "in", "on", "at", "for", "with", "from", "by", "as", "it", "its", + "his", "her", "their", "he", "she", "they", "him", "them", "not", "no", "than", + "then", "that", "this", "these", "those", "per", "via", "over", "under", "into", + "wants", "prefers", "values", "keeps", "runs", "does", "has", "have", "had", + "name", "role", "relation", "preference", "rule", "explicit", "inferred", "deduced", +]); + +function valueTokens(entry: string): { kind: string; values: string[] }[] { + const lower = entry.toLowerCase(); + const out: { kind: string; values: string[] }[] = []; + + const days = WEEKDAYS.filter((d) => new RegExp(`\\b${d}s?\\b`).test(lower)); + if (days.length > 0) out.push({ kind: "weekday", values: days.sort() }); + + const times = [...lower.matchAll(/\b(\d{1,2}:\d{2})\b/g)].map((m) => m[1]); + if (times.length > 0) out.push({ kind: "time", values: [...new Set(times)].sort() }); + + // Each unit is its own class. Comparing 7h against 40g as one "quantity" is + // meaningless and was the largest source of false positives in replay. + const unitAlt = UNITS.map((u) => u.replace("£", "\\£")).join("|"); + const byUnit = new Map(); + for (const m of lower.matchAll(new RegExp(`(\\d+(?:\\.\\d+)?)\\s*(${unitAlt})\\b`, "g"))) { + const unit = m[2]; + byUnit.set(unit, [...(byUnit.get(unit) ?? []), `${m[1]}${unit}`]); + } + for (const [unit, vals] of byUnit) { + out.push({ kind: `quantity:${unit}`, values: [...new Set(vals)].sort() }); + } + + return out; +} + +function topicAnchors(entry: string): Set { + const lower = entry.toLowerCase().replace(/~\w+\s*$/, ""); + const words = lower.match(/[a-z][a-z-]{2,}/g) ?? []; + return new Set( + words.filter((w) => !STOPWORDS.has(w) && !WEEKDAYS.includes(w) && !UNITS.includes(w)), + ); +} + +/** + * Pure. Given the prior entry list and the incoming one, return the incoming entries + * that assert a different closed-vocabulary value than a prior entry on the same topic. + * Exported so the behaviour is directly testable without a filesystem. + */ +export function detectValueConflicts(prior: string[], next: string[]): ValueConflict[] { + + const conflicts: ValueConflict[] = []; + + const nextSet = new Set(next); + + for (const entry of next) { + const incomingValues = valueTokens(entry); + if (incomingValues.length === 0) continue; + const anchors = topicAnchors(entry); + + for (const { kind, values } of incomingValues) { + // Gather every prior entry on this topic that carries a value of this class, + // and count how many DISTINCT values the prior state held. + // Includes a prior entry identical to the incoming one: when memory held both + // 40g and 60g and the reviewer kept the 40g line, the 40g line is part of + // the ambiguity it resolved, not an innocent bystander. + const onTopic: { entry: string; sig: string; values: string[] }[] = []; + for (const old of prior) { + const oldShared = [...topicAnchors(old)].filter((t) => anchors.has(t)); + // Two shared content words, not one. A single common token ("partner", + // "weight") links entries that are plainly about different things — a + // Saturday planning session and a Sunday phone call are not competing answers. + if (oldShared.length < MIN_SHARED_ANCHORS) continue; + const oldOfKind = valueTokens(old).find((v) => v.kind === kind); + if (!oldOfKind) continue; + onTopic.push({ entry: old, sig: oldOfKind.values.join("|"), values: oldOfKind.values }); + } + + // The signature that matters is DISAGREEMENT AMONG THE PRIOR ENTRIES. + // One prior value plus a different incoming value is ordinary supersession — + // the principal told us something new — and must not be downgraded. Two or + // more prior values means memory was already ambiguous, so choosing one of + // them (or a third) is the reviewer guessing. That is what we catch. + const distinctPrior = new Set(onTopic.map((o) => o.sig)); + const priorWasAmbiguous = distinctPrior.size >= 2; + + const clashing = onTopic.filter((o) => o.sig !== values.join("|")).map((o) => o.entry); + const priorValues = new Set(); + for (const o of onTopic) for (const v of o.values) priorValues.add(v); + + // A pick only happened if a disagreeing sibling was actually DROPPED. If the + // reviewer kept every conflicting entry, it declined to resolve — which is the + // behaviour we want — so there is nothing to downgrade. Note this is why an + // entry carried over verbatim can still be flagged: retaining one value while + // evicting the other is a choice, whether or not the text changed. + const resolvedByEviction = clashing.some((c) => !nextSet.has(c)); + + if (priorWasAmbiguous && clashing.length > 0 && resolvedByEviction) { + conflicts.push({ + entry, + prior: clashing, + kind, + values: { incoming: values, prior: [...priorValues].sort() }, + }); + } + } + } + + return conflicts; +} + +/** Force a conflicted entry's provenance down to ~inferred, preserving its text. */ +function downgradeProvenance(entry: string): string { + const stripped = entry.replace(/\s*~(explicit|deduced|inferred)\s*$/, ""); + return `${stripped} ~inferred`; +} + // ── Observability ── function logWriteEvent( @@ -461,6 +620,7 @@ function logWriteEvent( dropped_duplicates: result.dropped_duplicates, evictions: result.evictions, additions: result.additions, + value_conflicts: result.value_conflicts, }); appendFileSync(OBSERVABILITY_PATH, row + "\n", "utf8"); } catch { @@ -544,23 +704,31 @@ export function setEntries( } } + // Conflict rule: a reviewer pick between disagreeing values is a guess, so it + // may not be written at ~explicit. Detected deterministically (see above), then + // downgraded rather than rejected — curation keeps flowing, authority does not. + const value_conflicts = detectValueConflicts(priorEntries, newEntries); + const conflicted = new Map(value_conflicts.map((c) => [c.entry, downgradeProvenance(c.entry)])); + const finalEntries = newEntries.map((e) => conflicted.get(e) ?? e); + // Snapshot the prior content before we overwrite — individual-write recovery. snapshotBeforeWrite(abs, content); - const newContent = serializeMemoryContent(parsed, newEntries, options.updatedBy || "MemoryWriter"); + const newContent = serializeMemoryContent(parsed, finalEntries, options.updatedBy || "MemoryWriter"); const writeRes = atomicWrite(abs, newContent); if (writeRes !== true) return writeRes; const ok: SetEntriesOk = { ok: true, - accepted: newEntries.length, + accepted: finalEntries.length, dropped_malformed: validated.malformed, dropped_overlength: validated.overlength, dropped_duplicates: validated.duplicates, prior_count: priorEntries.length, - new_count: newEntries.length, + new_count: finalEntries.length, evictions, additions, + value_conflicts, }; logWriteEvent(abs, ok, options.updatedBy); return ok;