{"version":3,"file":"reduce.d.ts","sourceRoot":"","sources":["../../../src/core/learn/reduce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,WAAW,CAAC;AAEnE,6DAA6D;AAC7D,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,CAAC;AAE/D,oFAAoF;AACpF,MAAM,WAAW,UAAU;IAC1B,qEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,eAAe,CAAC;IACxB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,qDAAqD;IACrD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,8EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB,oFAAoF;IACpF,UAAU,EAAE,iBAAiB,EAAE,CAAC;CAChC;AAgID,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAiBjG;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,YAAY,EAAE,CAexF;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAY/F","sourcesContent":["/**\n * The reduce half of `/learn`: turn per-session observations into counts.\n *\n * The split with `mine.ts` is the whole design. Deciding that \"we're on bun\n * now\" and \"stop using npm\" mean the same thing is semantics, and the model is\n * better at it than any normalizer — so the model does it, by emitting a shared\n * `label`. Deciding that the shared label occurred nine times across five\n * sessions is arithmetic, and arithmetic stays here, because a model asked to\n * count over a long context will be approximately right, and an approximately\n * right number is worse than none when the number is the thing the reader acts\n * on.\n *\n * Nothing in this file filters on content. The only gate is the repeat\n * threshold, which is a dial the user sets and the digest reports, not a\n * whitelist they cannot see.\n */\n\nimport type { LabelledCandidate, MinedCandidate } from \"./mine.js\";\n\n/** Where a repeated directive already lives, if anywhere. */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\nexport interface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** The model's canonical name for what was meant. The clustering key. */\n\tlabel: string;\n\t/** Representative verbatim quote, the longest seen in the cluster. */\n\ttext: string;\n\t/** Why it is durable, in the model's words. */\n\trationale?: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so the reader saw this proposal and passed on it.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\tlabel: string;\n\t/** The failing command. */\n\tcommand: string;\n\t/** Short excerpt of the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\tcount: number;\n\tsessions: number;\n}\n\n/**\n * A piece of work the user keeps asking for by name.\n *\n * This is the slash-command signal, and it was being thrown away: the miner\n * used to be told not to report task requests at all. But a request repeated\n * across sessions is exactly what a slash command is for, and the evidence for\n * it is stronger than for most rules — the same job, asked for again, in\n * someone's own words.\n */\nexport interface RequestCandidate extends Proposable {\n\tlabel: string;\n\t/** How the user phrased it, so the proposal can quote rather than invent. */\n\ttext: string;\n\tcount: number;\n\tsessions: number;\n}\n\n/** One session's mining output, named and tagged with the identity the counts need. */\nexport interface MinedSession {\n\tsessionId: string;\n\t/** When the session was last written to — the clock suppression runs on. */\n\tlastActivity: string;\n\t/** Named by the global clustering pass; grouping here is exact-label arithmetic. */\n\tcandidates: LabelledCandidate[];\n}\n\ninterface Acc {\n\tlabel: string;\n\ttext: string;\n\trationale?: string;\n\tcount: number;\n\tsessions: Set<string>;\n\tlastSeen: string;\n\tsamples: MinedCandidate[];\n}\n\n/**\n * Group every candidate of one kind by its label.\n *\n * `lastSeen` takes the session's last activity rather than anything the model\n * reports: the model is reading a transcript and has no reliable clock, and\n * `lastSeen` drives suppression, where a wrong value silently hides a live\n * signal or resurfaces a dead one. It is deliberately not the session's start\n * either — a long-running session would date today's words to the day it was\n * opened, and suppression would call them old news.\n */\nfunction groupByLabel(sessions: MinedSession[], kind: MinedCandidate[\"kind\"]): Acc[] {\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const session of sessions) {\n\t\tfor (const candidate of session.candidates) {\n\t\t\tif (candidate.kind !== kind) continue;\n\t\t\tconst existing = acc.get(candidate.label);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.sessionId);\n\t\t\t\tif (session.lastActivity > existing.lastSeen) existing.lastSeen = session.lastActivity;\n\t\t\t\t// Keep the fullest quote: a longer one carries more of the reasoning.\n\t\t\t\tif (candidate.text.length > existing.text.length) existing.text = candidate.text;\n\t\t\t\texisting.rationale ??= candidate.rationale;\n\t\t\t\texisting.samples.push(candidate);\n\t\t\t} else {\n\t\t\t\tacc.set(candidate.label, {\n\t\t\t\t\tlabel: candidate.label,\n\t\t\t\t\ttext: candidate.text,\n\t\t\t\t\trationale: candidate.rationale,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.sessionId]),\n\t\t\t\t\tlastSeen: session.lastActivity,\n\t\t\t\t\tsamples: [candidate],\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()];\n}\n\n/**\n * Merge accumulators whose representative quote is the same sentence.\n *\n * The model labels each session independently and has no way to see what it\n * called the same thing last time, so identical text routinely arrives under\n * two labels — and then reads as two separate proposals, the same words twice.\n * Matching on the quote costs nothing and is the one case where sameness is not\n * a judgement call.\n *\n * This runs before the repeat threshold on purpose: two occurrences split\n * across two labels are each below the bar, and merging them afterwards would\n * mean neither was ever considered.\n *\n * The surviving label is the lexicographically smallest of the merged set, not\n * the first one seen. First-seen follows session order, which changes whenever\n * a session is added — so the merged cluster would silently change its name\n * between runs, the state key with it, and every item the reader had already\n * decided on would come back as new. A merge has to be a pure function of what\n * was merged.\n */\nfunction mergeIdenticalText(entries: Acc[]): Acc[] {\n\tconst byText = new Map<string, Acc>();\n\tconst order: Acc[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst key = entry.text.replace(/\\s+/g, \" \").trim().toLowerCase();\n\t\tconst existing = byText.get(key);\n\t\tif (!existing) {\n\t\t\tbyText.set(key, entry);\n\t\t\torder.push(entry);\n\t\t\tcontinue;\n\t\t}\n\t\texisting.count += entry.count;\n\t\tfor (const session of entry.sessions) existing.sessions.add(session);\n\t\tif (entry.lastSeen > existing.lastSeen) existing.lastSeen = entry.lastSeen;\n\t\texisting.rationale ??= entry.rationale;\n\t\texisting.samples.push(...entry.samples);\n\t\tif (entry.label < existing.label) existing.label = entry.label;\n\t}\n\n\treturn order;\n}\n\n/** Distinct sessions first, then raw count: five sessions beats nine times in one. */\nfunction byEvidence(a: { sessions: number; count: number; label: string }, b: typeof a): number {\n\treturn b.sessions - a.sessions || b.count - a.count || a.label.localeCompare(b.label);\n}\n\n/** Merge repeated string fields across a cluster's samples, preserving order and dropping dupes. */\nfunction mergeStrings(samples: MinedCandidate[], pick: (c: MinedCandidate) => string[] | undefined): string[] {\n\tconst out: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const sample of samples) {\n\t\tfor (const value of pick(sample) ?? []) {\n\t\t\tif (seen.has(value)) continue;\n\t\t\tseen.add(value);\n\t\t\tout.push(value);\n\t\t}\n\t}\n\treturn out.slice(0, 12);\n}\n\n/**\n * The repeat threshold counts distinct sessions, not occurrences.\n *\n * Saying a thing twice inside one session is the commonest thing in a\n * transcript and usually means the opposite of durable: the agent ignored it\n * the first time, so it was restated. Two *sessions* is a claim about how you\n * work; two lines in one session is a claim about one afternoon.\n */\nfunction repeatedEnough(entry: Acc, minRepeats: number): boolean {\n\treturn entry.sessions.size >= minRepeats;\n}\n\nexport function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"directive\"))\n\t\t.filter((entry) => repeatedEnough(entry, minRepeats))\n\t\t.map((entry) => ({\n\t\t\tkey: `directive:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\n\t\t\trationale: entry.rationale,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\t// Coverage is decided later, by a model that can tell a paraphrase from a\n\t\t\t// coincidence. Everything starts `new` and is corrected in place.\n\t\t\tstatus: \"new\" as DirectiveStatus,\n\t\t\tpreviouslyDeclined: false,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceFixes(sessions: MinedSession[], minRepeats: number): FixCandidate[] {\n\treturn groupByLabel(sessions, \"fix\")\n\t\t.filter((entry) => repeatedEnough(entry, minRepeats))\n\t\t.map((entry) => ({\n\t\t\tkey: `fix:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\tcommand: entry.samples.find((s) => s.command)?.command ?? entry.text,\n\t\t\terrorExcerpt: entry.samples.find((s) => s.errorExcerpt)?.errorExcerpt ?? \"\",\n\t\t\tinterveningCommands: mergeStrings(entry.samples, (s) => s.interveningCommands),\n\t\t\teditedFiles: mergeStrings(entry.samples, (s) => s.editedFiles),\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceRequests(sessions: MinedSession[], minRepeats: number): RequestCandidate[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"request\"))\n\t\t.filter((entry) => repeatedEnough(entry, minRepeats))\n\t\t.map((entry) => ({\n\t\t\tkey: `request:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n"]}