From b88a04ca810f9c7add43d9a5bc2aced72a51dfe1 Mon Sep 17 00:00:00 2001 From: UIOSUN <92213066+U1oSun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:44:57 +0800 Subject: [PATCH] feat(analysis): mine native recurring-correction candidates Add a bounded, privacy-safe review packet that screens ordinary Task Episodes for recurring corrections and applies evidence-bound match or abstain decisions through the existing Learning Loop owner. Implements roadmap LC-05 using docs/specs/2026-08-01-lc05-native-learning-candidate-mining.md. Validated with 37 focused tests, doc-link integrity, package verification, and the full suite aside from four Windows symlink EPERM cases. Co-authored-by: Codex (GPT 5.6 Sol) --- CHANGELOG.md | 8 + ...1-lc05-native-learning-candidate-mining.md | 188 +++++++ .../learning-loop-candidates.mjs | 316 ++++++++++- .../learning-loop-review-packet.mjs | 456 +++++++++++++++ scripts/npm-package/verify-pack.mjs | 4 + scripts/session-analysis/episode-contract.mjs | 15 +- .../native-learning-candidate-review.test.mjs | 528 ++++++++++++++++++ 7 files changed, 1509 insertions(+), 6 deletions(-) create mode 100644 docs/specs/2026-08-01-lc05-native-learning-candidate-mining.md create mode 100644 scripts/harness-analysis/learning-loop-review-packet.mjs create mode 100644 test/native-learning-candidate-review.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd6cbb..a77d827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ observable behavior and compatibility, not every internal refactor. ## Unreleased +### Added + +- A read-only native Learning Capture review contract can now screen ordinary + Task Episodes for repeated exact repair routes, emit a bounded privacy-safe + packet, validate evidence-bound `match` or `abstain` decisions, and project + accepted `recurring-correction` opportunities through the existing Learning + Loop candidate model without requiring adapter-supplied pattern labels. + ### Fixed - Checkup plan/apply is provider-aware: only `provider=qoder` can emit or execute diff --git a/docs/specs/2026-08-01-lc05-native-learning-candidate-mining.md b/docs/specs/2026-08-01-lc05-native-learning-candidate-mining.md new file mode 100644 index 0000000..cd5b629 --- /dev/null +++ b/docs/specs/2026-08-01-lc05-native-learning-candidate-mining.md @@ -0,0 +1,188 @@ +# Native Learning Candidate Mining + +## Traceability + +- Spec ID: `lc05-native-learning-candidate-mining` +- Roadmap: `LC-05` +- Status: Implemented + +## Intent + +Allow ordinary normalized Task Episodes to produce evidence-bound Learning Loop +candidate inputs without requiring an adapter-supplied `learningPatternId`. +The first slice deterministically prepares a bounded, privacy-safe review packet, +accepts an external review decision of `match` or `abstain`, validates that +decision against the packet, and then feeds accepted signals through the existing +`buildLearningLoopReview()` contract. + +This is a read-only discovery method. It may identify an `opportunity`, but it +does not call a model, mutate a Harness asset, authorize an intervention, or +claim that an intervention is effective. + +## Acceptance Scenarios + +- **AC-1: Bounded ordinary-Episode packet:** Given normalized Task Episodes + with no adapter-supplied `learningPatternId`, deterministic screening emits a + versioned review packet containing only bounded structural facts, opaque + Episode and evidence aliases, allowed review enums, a source binding over + only those projected privacy-safe facts, and a packet digest. Raw Episode, + prompt, path, or transcript content is never directly hashed. Input Episode + order does not change the packet or digest. +- **AC-2: First-slice pattern boundary:** A reviewed native match can produce + only `recurring-correction`, backed by an ordinary Episode's explicit + correction observation or exact failure-edit-same-check-rerun repair shape. + Provider-supplied `repeated-rediscovery` signals remain supported by the + existing path, but native rediscovery waits for a privacy-safe work/read trace. + The resulting native signal has + `ai-reviewed` field provenance and remains an `opportunity`; no readiness or + effectiveness claim is produced. +- **AC-3: Match and abstain:** Every deterministically screened group receives + exactly one `match` or `abstain` decision. A match requires at least two + distinct Episode aliases and evidence from every referenced Episode. Missing, + incomplete, or insufficiently comparable evidence must remain an abstention + or an explicit non-clean coverage reason. +- **AC-4: Hard-negative identity boundary:** Episodes are not grouped merely + because both failed. They need a shared privacy-safe request/task route, + target, or check identity plus structural friction. Different task, target, + and check identities therefore abstain before AI review. +- **AC-5: Protective behavior exclusion:** Permission, Hook, or Guardrail + denials identified as protective interventions are retained as a minimal + ordinary Task Episode fact, excluded from native friction groups, and cannot + become learning candidates. +- **AC-6: Deterministic binding:** Review decisions may reference only group, + Episode, evidence, pattern, and reason values allowed by the packet. Unknown + or invented refs, a stale source/packet digest, duplicate decisions, or + decision/group mismatches fail closed before candidate construction. +- **AC-7: Existing signal compatibility:** Provider-supplied learning signals + and the historical flat event-to-`learningSignals` path remain supported. + Missing `userCorrection` stays unavailable instead of being materialized as + observed false, while explicit false and true values retain provenance. + Applying a native review appends rather than replaces signals and avoids + duplicating an equivalent provider/native pattern and signature. +- **AC-8: Boolean evidence states:** Missing observations are represented as + `unavailable`, not coerced to `false`; explicitly observed false and true + values remain distinguishable through field provenance or field-coverage + evidence in packet facts and validation. +- **AC-9: Privacy boundary:** Packet and apply output contain no raw prompt, + command, transcript, session identifier, credential, email address, or + Windows, POSIX, or UNC home path. Opaque aliases bind privacy-safe source + evidence IDs or public fingerprints, and each bounded Episode fact carries an + aggregate digest over the complete accepted identity set. A change outside + the displayed evidence limit therefore still invalidates an old review. + Apply output keeps aliases and the aggregate binding opaque; an authorized + caller can deterministically re-derive the association from the source, but + the public artifact never resolves or exposes raw IDs. +- **AC-10: Evaluation fixtures:** Positive and hard-negative fixtures report + expected match, abstain, and coverage counts for native + `recurring-correction` plus legacy-signal compatibility, + including single-Episode, protective-intervention, unrelated-failure, + reordered-input, invented-ref, legacy/native-deduplication, tri-state boolean, + and privacy-sentinel cases. + +## Non-goals + +- Do not call an AI model or add provider-specific model routing. +- Do not natively infer patterns beyond first-slice `recurring-correction`. +- Do not natively infer `repeated-rediscovery` from repair, tool-count, or + target-overlap proxies; add it only after an ordinary privacy-safe work/read + trace exists. Existing provider-supplied signals remain supported. +- Do not auto-create or modify Memory, Rule, Skill, Hook, Gate, Eval, or other + Harness components. +- Do not run, schedule, retain, or revert an intervention. +- Do not claim effectiveness, transfer, causality, or cost savings. +- Do not expose raw transcript text to the reviewer. +- Do not replace adapter-supplied learning signals or broaden provider adapters. +- Do not change the report-source schema or automatically insert unreviewed + native candidates into normal report generation. + +## Plan and Tasks + +1. Add a capability-owned Learning Loop review-packet module under + `scripts/harness-analysis/`. It normalizes only privacy-safe structural + Episode facts, screens bounded comparable pairs, builds opaque alias maps by + deterministic recomputation, and publishes the allowed decision contract. +2. Extend the existing capability owner in `learning-loop-candidates.mjs` with a + deterministic apply API that validates source and packet digests, exact + decision coverage, enum domains, distinct Episode membership, evidence + ownership, privacy-safe reason codes, and the packet allowlist. +3. For accepted matches, append bounded `learningSignals` to cloned Episodes, + preserve provider signals, reuse an equivalent provider signature when all + matched Episodes already carry it, and invoke the existing + `buildLearningLoopReview()` implementation. +4. Add focused fixtures that measure positive matches and abstentions while + exercising hard negatives, privacy sentinels, binding failures, signal + compatibility, and deterministic ordering. +5. Run focused tests, document-link integrity, the full test suite, and package + verification. Perform a Review Readiness Check before any commit. + +## Privacy and Risk + +- **False grouping:** Shared failure status alone can merge unrelated work. + Mitigation: require shared normalized task/target/check identity and explicit + structural friction, then require an evidence-bound human/AI match decision. +- **Protective behavior mislabeled as waste:** Permission and Hook denials can + be expected safeguards. Mitigation: protective signals fail closed out of + native groups. +- **Reviewer invention:** A reviewer could add Episode or evidence refs that + were never observed. Mitigation: opaque allowlists, per-Episode evidence + ownership, exact group membership, and digest binding. +- **Private content leakage:** Episode IDs, source refs, paths, credentials, or + text could escape through packets or signatures. Mitigation: opaque aliases, + enum-only reviewer prose, no raw text fields, privacy sentinel validation, + deterministic signatures, and source binding over projected safe facts only. +- **Overstated coverage:** Truncation or absent observations could look clean. + Mitigation: explicit `unavailable`, `insufficient-*`, and `truncated` coverage + codes; the method never emits `checked-clean` for missing or truncated facts. +- **Contract regression:** Native signals could overwrite or duplicate provider + signals. Mitigation: clone-and-append semantics and equivalent-signal dedupe. + +## Test and Review Evidence + +Planned evidence, to be replaced or augmented only by actual command results: + +- AC-1, AC-4, AC-5, AC-8, AC-9: + `node --test test/native-learning-candidate-review.test.mjs` +- AC-2, AC-3, AC-6, AC-7, AC-10: + `node --test test/native-learning-candidate-review.test.mjs test/learning-loop-review.test.mjs` +- Documentation integrity: + `node --test test/doc-link-graph.test.mjs` +- Repository regression evidence: `npm test` +- Packaged-runtime evidence: `npm run pack:verify` + +## Implementation Evidence + +- `learning-loop-review-packet.mjs` owns the versioned privacy-safe projection, + deterministic screening, membership-independent pattern signature, bounded + coverage, digest binding, and packet replay validation (AC-1, AC-3 to AC-6, + AC-8, AC-9). +- `learning-loop-candidates.mjs` exposes the packet API through the existing + Learning Loop owner and applies reviewed matches through + `buildLearningLoopReview()` while preserving and deduplicating provider + signals (AC-2, AC-6, AC-7). +- `episode-contract.mjs` records an ordinary Episode's protective-intervention + observation and keeps a provider's missing `userCorrection` observation + unavailable instead of coercing it to false (AC-5, AC-8). +- `native-learning-candidate-review.test.mjs` uses ordinary + `buildTaskEpisodes()` failure-edit-same-check-rerun events with no + `learningSignals` for the native positive path. It also covers three-Episode + grouping, hard negatives, ordinary Hook protection, abstention shape, + invented refs, evidence-identity replay binding inside and beyond the bounded + display limit, per-Episode evidence ownership, mixed correction/repair + friction, distinct-Episode handling, tri-state observations, privacy + sentinels, truncation, reorder stability, and legacy/flat-signal compatibility + (AC-1 to AC-10). +- Focused evidence passed after the independent-review revisions: 37/37 tests + from + `node --test test/native-learning-candidate-review.test.mjs test/learning-loop-review.test.mjs test/session-episode-contract.test.mjs`. +- Documentation integrity passed: 6/6 tests from + `node --test test/doc-link-graph.test.mjs`. +- The final post-review full `npm test` completed with 1053 passing, 4 failing, + and 6 skipped tests. + All four failures are pre-existing Windows `EPERM` failures where the local + account cannot create test symlinks; none touch the changed modules or focused + LC-05 tests. +- Final package verification passed with an isolated temporary npm cache + configuration; `npm run pack:verify` verified 369 npm entries and 392 runtime + ZIP entries. +- `git diff --check` passed. No generated documentation graph changed, and no + files are staged in this worktree. diff --git a/scripts/harness-analysis/learning-loop-candidates.mjs b/scripts/harness-analysis/learning-loop-candidates.mjs index fcc72ec..1cf07a9 100644 --- a/scripts/harness-analysis/learning-loop-candidates.mjs +++ b/scripts/harness-analysis/learning-loop-candidates.mjs @@ -1,6 +1,27 @@ import { createHash } from "node:crypto"; +import { + buildNativeLearningReviewPacket, + NATIVE_LEARNING_ABSTAIN_REASON_CODES, + NATIVE_LEARNING_MATCH_REASON_CODES, + NATIVE_LEARNING_PATTERN_IDS, + NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION, + validateNativeLearningReviewPacket, +} from "./learning-loop-review-packet.mjs"; + +export { + buildNativeLearningReviewPacket, + nativeLearningReviewPacketDigest, + NATIVE_LEARNING_ABSTAIN_REASON_CODES, + NATIVE_LEARNING_MATCH_REASON_CODES, + NATIVE_LEARNING_PATTERN_IDS, + NATIVE_LEARNING_REVIEW_DECISIONS, + NATIVE_LEARNING_REVIEW_KIND, + NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION, + validateNativeLearningReviewPacket, +} from "./learning-loop-review-packet.mjs"; export const LEARNING_LOOP_REVIEW_SCHEMA_VERSION = 1; +export const NATIVE_LEARNING_APPLIED_REVIEW_SCHEMA_VERSION = 1; export const LEARNING_LOOP_PATTERN_IDS = Object.freeze([ "repeated-rediscovery", @@ -175,6 +196,18 @@ function fieldState(value, provenance, observed = true) { }; } +function booleanFieldObservation(signal, field) { + const value = signal?.[field]; + const coverage = signal?.fieldEvidence?.[field]?.coverage; + if (coverage === "unavailable") return { value: false, observed: false }; + if (coverage === "observed" && typeof value === "boolean") return { value, observed: true }; + const provenance = signal?.fieldProvenance?.[field]; + if (typeof value === "boolean" && (value === true || FIELD_PROVENANCE.has(provenance))) { + return { value, observed: true }; + } + return { value: false, observed: false }; +} + function defaultOwner(patternId, signal = {}) { if (signal.mandatory === true || patternId === "unsafe-mutable-memory") return "Gate"; if (patternId === "recurring-correction") return signal.procedural === true ? "Skill" : "Rule"; @@ -206,7 +239,7 @@ function claimType(patternId) { function candidateCopy(patternId, count) { const copies = { "repeated-rediscovery": ["Comparable episodes repeated the same discovery route.", "Make the route retrievable before comparable work starts."], - "recurring-correction": ["Comparable episodes repeated the same explicit correction.", "Move the correction into the smallest durable owner and exercise it on later work."], + "recurring-correction": ["Comparable episodes repeated the same explicit correction or exact repair route.", "Move the correction into the smallest durable owner and exercise it on later work."], "successful-procedure-not-reused": ["A successful procedure was available but comparable work repeated the earlier exploration.", "Codify and route the successful procedure for later tasks."], "present-but-not-routed": ["A relevant durable asset existed but was not selected for the matching task.", "Repair its trigger, namespace, or index and test positive and negative routing."], "routed-but-not-applied": ["A relevant asset was loaded but its required step was not applied.", "Tighten the procedure or promote mandatory behavior to a gate."], @@ -229,6 +262,7 @@ function candidateCopy(patternId, count) { export function normalizeLearningLoopEpisodes(episodes = []) { return rows(episodes).map((episode) => { const signals = rows(episode?.learningSignals).map((signal) => { + const userCorrection = booleanFieldObservation(signal, "userCorrection"); const patternId = PATTERN_SET.has(signal?.patternId) ? signal.patternId : null; const taskFamily = slug(signal?.taskFamily ?? episode?.taskFamily); const repoArea = slug(signal?.repoArea ?? rows(episode?.targetKeys)[0], "unobserved"); @@ -247,7 +281,7 @@ export function normalizeLearningLoopEpisodes(episodes = []) { repoArea, changeType: slug(signal?.changeType, rows(episode?.changeSets).length > 0 ? "edit" : "unobserved"), frictionType, - userCorrection: signal?.userCorrection === true, + userCorrection: userCorrection.value, ...(asset ? { asset } : {}), assetLoaded: signal?.assetLoaded === true, assetChanged: signal?.assetChanged === true, @@ -267,7 +301,7 @@ export function normalizeLearningLoopEpisodes(episodes = []) { changeType: fieldState(signal?.changeType, provenance("changeType", signal?.changeType ? "ai-reviewed" : "deterministic-derived"), Boolean(signal?.changeType) || rows(episode?.changeSets).length > 0), frictionType: fieldState(signal?.frictionType, provenance("frictionType", "ai-reviewed"), Boolean(signal?.frictionType)), normalizedSignature: fieldState(normalizedSignature, provenance("normalizedSignature", suppliedSignature ? "ai-reviewed" : "deterministic-derived")), - userCorrection: fieldState(signal?.userCorrection, provenance("userCorrection", "ai-reviewed"), signal?.userCorrection !== undefined), + userCorrection: fieldState(signal?.userCorrection, provenance("userCorrection", "ai-reviewed"), userCorrection.observed), asset: fieldState(asset, provenance("asset", asset ? "ai-reviewed" : "host-observed"), Boolean(asset)), assetLoaded: fieldState(signal?.assetLoaded, provenance("assetLoaded", "host-observed"), signal?.assetLoaded !== undefined), assetRelevant: fieldState(signal?.assetRelevant, provenance("assetRelevant", "ai-reviewed"), signal?.assetRelevant !== undefined), @@ -448,6 +482,282 @@ export function buildLearningLoopReview({ episodes = [], signals = {}, intervent }; } +function nativeReviewAllowedFields(value, allowed, location, errors) { + if (!isObject(value)) { + errors.push(location + " must be an object"); + return; + } + for (const field of Object.keys(value)) { + if (!allowed.has(field)) errors.push(location + " has unsupported field: " + field); + } +} + +function exactSortedStrings(value) { + return rows(value).map((item) => String(item ?? "")).sort(); +} + +function sameStringSet(left, right) { + return JSON.stringify(exactSortedStrings(left)) === JSON.stringify(exactSortedStrings(right)); +} + +function validateNativeDecision(decision, index, group, errors) { + const location = "native learning review decisions[" + index + "]"; + nativeReviewAllowedFields( + decision, + new Set(["groupRef", "decision", "patternId", "episodeRefs", "evidenceRefs", "reasonCodes"]), + location, + errors, + ); + if (!["match", "abstain"].includes(decision?.decision)) { + errors.push(location + ".decision must be match or abstain"); + return; + } + const reasonCodes = rows(decision?.reasonCodes); + for (const field of ["episodeRefs", "evidenceRefs"]) { + if (decision?.[field] !== undefined && !Array.isArray(decision[field])) { + errors.push(location + "." + field + " must be an array when present"); + } + } + if (reasonCodes.length === 0 || new Set(reasonCodes).size !== reasonCodes.length) { + errors.push(location + ".reasonCodes must contain distinct reason codes"); + } + if (decision.decision === "abstain") { + if (decision.patternId !== undefined) errors.push(location + ".patternId must be omitted for abstain"); + if (rows(decision.episodeRefs).length > 0) errors.push(location + ".episodeRefs must be empty for abstain"); + if (rows(decision.evidenceRefs).length > 0) errors.push(location + ".evidenceRefs must be empty for abstain"); + if (reasonCodes.some((code) => !NATIVE_LEARNING_ABSTAIN_REASON_CODES.includes(code))) { + errors.push(location + ".reasonCodes contains an unsupported abstain reason"); + } + return; + } + if (!NATIVE_LEARNING_PATTERN_IDS.includes(decision.patternId) + || !group.allowedPatternIds.includes(decision.patternId)) { + errors.push(location + ".patternId is not allowed for this group"); + } + if (!sameStringSet(decision.episodeRefs, group.episodeRefs) + || new Set(rows(decision.episodeRefs)).size !== rows(decision.episodeRefs).length) { + errors.push(location + ".episodeRefs must match the group distinct Episode allowlist"); + } + const allowedEvidence = new Set(group.evidenceRefs); + const suppliedEvidence = rows(decision.evidenceRefs); + if (suppliedEvidence.length === 0 || new Set(suppliedEvidence).size !== suppliedEvidence.length) { + errors.push(location + ".evidenceRefs must contain distinct packet evidence refs"); + } + if (suppliedEvidence.some((reference) => !allowedEvidence.has(reference))) { + errors.push(location + ".evidenceRefs contains a ref outside the packet allowlist"); + } + for (const episodeRef of group.episodeRefs) { + const owned = new Set(rows(group.evidenceOwnership?.[episodeRef])); + if (!suppliedEvidence.some((reference) => owned.has(reference))) { + errors.push(location + ".evidenceRefs requires evidence owned by " + episodeRef); + } + } + if (reasonCodes.some((code) => !NATIVE_LEARNING_MATCH_REASON_CODES.includes(code) + || !group.reasonCodes.includes(code))) { + errors.push(location + ".reasonCodes contains a reason not supported by the packet group"); + } + const identityReasons = new Set(["same-task-route", "same-target", "same-check"]); + if (!reasonCodes.some((code) => identityReasons.has(code))) { + errors.push(location + ".reasonCodes requires one shared identity reason"); + } + const hasCorrectionReason = reasonCodes.includes("explicit-user-correction") + || reasonCodes.includes("same-check-repair"); + if (!hasCorrectionReason) errors.push(location + ".reasonCodes requires a supported recurring correction reason"); +} + +export function validateNativeLearningCandidateReview({ episodes = [], packet, review } = {}) { + const errors = [...validateNativeLearningReviewPacket({ episodes, packet })]; + nativeReviewAllowedFields( + review, + new Set(["schemaVersion", "sourceDigest", "packetDigest", "decisions"]), + "native learning review", + errors, + ); + if (review?.schemaVersion !== NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION) { + errors.push("native learning review schemaVersion must be " + NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION); + } + if (review?.sourceDigest !== packet?.sourceDigest) { + errors.push("native learning review sourceDigest does not match the review packet"); + } + if (review?.packetDigest !== packet?.packetDigest) { + errors.push("native learning review packetDigest does not match the review packet"); + } + if (!Array.isArray(review?.decisions)) errors.push("native learning review decisions must be an array"); + const groups = rows(packet?.groups); + const groupMap = new Map(groups.map((group) => [group.groupRef, group])); + const counts = new Map(); + for (const [index, decision] of rows(review?.decisions).entries()) { + const groupRef = String(decision?.groupRef ?? ""); + counts.set(groupRef, (counts.get(groupRef) ?? 0) + 1); + const group = groupMap.get(groupRef); + if (!group) { + errors.push("native learning review decisions[" + index + "].groupRef is not present in the review packet"); + continue; + } + validateNativeDecision(decision, index, group, errors); + } + for (const group of groups) { + if ((counts.get(group.groupRef) ?? 0) !== 1) { + errors.push("native learning review requires exactly one decision for group " + group.groupRef); + } + } + for (const [groupRef, count] of counts) { + if (count > 1 && groupMap.has(groupRef)) { + errors.push("native learning review duplicates decision for group " + groupRef); + } + } + return [...new Set(errors)].sort(); +} + +function nativeSignalSignature(group) { + return String(group?.patternSignature ?? ""); +} + +function commonProviderSignature(episodes, patternId) { + const signatureSets = episodes.map((episode) => new Set(rows(episode?.learningSignals) + .filter((signal) => signal?.patternId === patternId && text(signal?.normalizedSignature)) + .map((signal) => text(signal.normalizedSignature)))); + if (signatureSets.length === 0) return ""; + return [...signatureSets[0]] + .filter((signature) => signatureSets.every((set) => set.has(signature))) + .sort()[0] ?? ""; +} + +function dedupeLearningSignals(signals) { + const seen = new Set(); + return rows(signals).filter((signal, index) => { + const patternId = text(signal?.patternId); + const normalizedSignature = text(signal?.normalizedSignature); + const key = patternId && normalizedSignature + ? patternId + "\u0000" + normalizedSignature + : "unkeyed\u0000" + index; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function reviewedNativeSignal({ decision, group, signature, episodeFact }) { + const explicitUserCorrection = decision.reasonCodes.includes("explicit-user-correction") + && episodeFact?.facts?.userCorrection === "true"; + const exactRepair = decision.reasonCodes.includes("same-check-repair") + && episodeFact?.facts?.sameCheckRepair === "true"; + const taskFamily = rows(group?.sharedIdentity?.taskRoutes)[0]; + const repoArea = rows(group?.sharedIdentity?.targetRefs)[0]; + const ownedEvidence = new Set(rows(group?.evidenceOwnership?.[episodeFact?.episodeRef])); + const signal = { + patternId: decision.patternId, + normalizedSignature: signature, + frictionType: explicitUserCorrection ? "user-correction" : exactRepair ? "same-check-repair" : "unobserved", + procedural: false, + fieldProvenance: { + frictionType: "ai-reviewed", + normalizedSignature: "deterministic-derived", + }, + evidenceRefs: rows(decision.evidenceRefs).filter((reference) => ownedEvidence.has(reference)).map((reference) => ({ + kind: "native-learning-evidence", + id: reference, + })), + nativeReview: { + groupRef: group.groupRef, + reasonCodes: [...decision.reasonCodes], + }, + }; + if (taskFamily) { + signal.taskFamily = taskFamily; + signal.fieldProvenance.taskFamily = "deterministic-derived"; + } + if (repoArea) { + signal.repoArea = repoArea; + signal.fieldProvenance.repoArea = "deterministic-derived"; + } + if (explicitUserCorrection) { + signal.userCorrection = true; + signal.fieldProvenance.userCorrection = "deterministic-derived"; + } + return signal; +} + +function appliedNativeCoverage(packet, decisions) { + const matchedGroupCount = decisions.filter((decision) => decision.decision === "match").length; + const abstainedGroupCount = decisions.filter((decision) => decision.decision === "abstain").length; + return { + status: packet?.coverage?.truncated === true ? "reviewed-partial" : "reviewed", + candidateGroupCount: rows(packet?.groups).length, + matchedGroupCount, + abstainedGroupCount, + coverageReasonCodes: rows(packet?.coverage?.reasonCodes), + }; +} + +export function applyNativeLearningCandidateReview({ + episodes = [], + packet, + review, + signals = {}, + interventions = [], + assetCoverage = [], +} = {}) { + const errors = validateNativeLearningCandidateReview({ episodes, packet, review }); + if (errors.length > 0) return { result: null, errors }; + + const reviewedEpisodes = structuredClone(rows(episodes)); + const episodesById = new Map(rows(reviewedEpisodes).map((episode) => [String(episode?.id ?? ""), episode])); + const episodeFactsById = new Map(rows(packet?.episodeFacts).map((episode) => [episode.episodeRef, episode])); + const groupsById = new Map(rows(packet?.groups).map((group) => [group.groupRef, group])); + const matches = []; + const abstentions = []; + for (const decision of rows(review?.decisions)) { + const group = groupsById.get(decision.groupRef); + if (decision.decision === "abstain") { + abstentions.push({ groupRef: decision.groupRef, reasonCodes: [...decision.reasonCodes] }); + continue; + } + const sourceEpisodes = group.episodeRefs.map((episodeRef) => episodesById.get(episodeRef)); + const signature = commonProviderSignature(sourceEpisodes, decision.patternId) || nativeSignalSignature(group); + for (const episode of sourceEpisodes) { + episode.learningSignals = dedupeLearningSignals(episode.learningSignals); + const exists = episode.learningSignals.some((signal) => signal?.patternId === decision.patternId + && text(signal?.normalizedSignature) === signature); + if (!exists) episode.learningSignals.push(reviewedNativeSignal({ + decision, + group, + signature, + episodeFact: episodeFactsById.get(episode.id), + })); + } + matches.push({ + groupRef: decision.groupRef, + patternId: decision.patternId, + sourceEpisodes: [...group.episodeRefs], + normalizedSignature: signature, + evidenceRefs: decision.evidenceRefs.map((reference) => ({ + kind: "native-learning-evidence", + id: reference, + })), + reasonCodes: [...decision.reasonCodes], + claimType: "opportunity", + }); + } + + const learningLoop = buildLearningLoopReview({ episodes: reviewedEpisodes, signals, interventions, assetCoverage }); + learningLoop.status = "reviewed"; + return { + result: { + schemaVersion: NATIVE_LEARNING_APPLIED_REVIEW_SCHEMA_VERSION, + kind: "native-learning-candidate-review", + status: "reviewed", + sourceDigest: packet.sourceDigest, + packetDigest: packet.packetDigest, + matches, + abstentions, + coverage: appliedNativeCoverage(packet, review.decisions), + learningLoop, + }, + errors: [], + }; +} + export function validateLearningLoopReview(value, { episodeIds = [] } = {}) { if (!isObject(value)) return ["learning loop review must be an object"]; const errors = []; diff --git a/scripts/harness-analysis/learning-loop-review-packet.mjs b/scripts/harness-analysis/learning-loop-review-packet.mjs new file mode 100644 index 0000000..a808e00 --- /dev/null +++ b/scripts/harness-analysis/learning-loop-review-packet.mjs @@ -0,0 +1,456 @@ +import { createHash } from "node:crypto"; + +export const NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION = 1; +export const NATIVE_LEARNING_REVIEW_KIND = "native-learning-candidate-review-packet"; + +export const NATIVE_LEARNING_PATTERN_IDS = Object.freeze([ + "recurring-correction", +]); + +export const NATIVE_LEARNING_REVIEW_DECISIONS = Object.freeze(["match", "abstain"]); +export const NATIVE_LEARNING_MATCH_REASON_CODES = Object.freeze([ + "same-task-route", + "same-target", + "same-check", + "explicit-user-correction", + "same-check-repair", +]); +export const NATIVE_LEARNING_ABSTAIN_REASON_CODES = Object.freeze([ + "insufficient-comparability", + "insufficient-evidence", + "pattern-not-supported", +]); + +const DEFAULT_LIMITS = Object.freeze({ + maxEpisodes: 24, + maxGroups: 64, + maxEvidenceRefsPerEpisode: 12, +}); +const SAFE_EPISODE_ID_RE = /^episode:[a-f0-9]{12,64}$/u; +const SAFE_TARGET_KEY_RE = /^(?:target:)?[a-f0-9]{12,64}$/u; +const SAFE_CHECK_ID_RE = /^(?:check:)?[a-f0-9]{12,64}$/u; +const SAFE_ROUTE_RE = /^(?:lifecycle:)?[a-z0-9][a-z0-9._:/-]{0,79}$/u; +const SAFE_EVIDENCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u; +const SAFE_EVIDENCE_FINGERPRINT_RE = /^[a-f0-9]{16,128}$/u; +const PRIVATE_EVIDENCE_ID_RE = /(?:^session(?:[-_:]|$)|@|:\/\/|^[A-Za-z]:[\\/]|^\\\\|^\/(?:Users|home)\/)/iu; +const FAILED_STATUS = new Set(["failed", "failure", "error", "errored"]); +const PROTECTIVE_FRICTION = "protective-intervention"; +const OBSERVED_PROVENANCE = new Set(["host-observed", "deterministic-derived", "ai-reviewed"]); + +function rows(value) { + return Array.isArray(value) ? value.filter(Boolean) : []; +} + +function canonicalValue(value) { + if (Array.isArray(value)) return value.map(canonicalValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])])); +} + +function digest(value, length = 64) { + return createHash("sha256").update(JSON.stringify(canonicalValue(value))).digest("hex").slice(0, length); +} + +function uniqueSorted(values) { + return [...new Set(values.filter(Boolean))].sort(); +} + +function boundedInteger(value, fallback, minimum = 1, maximum = 256) { + const number = Number(value); + return Number.isInteger(number) && number >= minimum && number <= maximum ? number : fallback; +} + +function normalizedLimits(value = {}) { + return { + maxEpisodes: boundedInteger(value.maxEpisodes, DEFAULT_LIMITS.maxEpisodes), + maxGroups: boundedInteger(value.maxGroups, DEFAULT_LIMITS.maxGroups), + maxEvidenceRefsPerEpisode: boundedInteger( + value.maxEvidenceRefsPerEpisode, + DEFAULT_LIMITS.maxEvidenceRefsPerEpisode, + ), + }; +} + +function observationState(values) { + if (values.some((value) => value === true)) return "true"; + if (values.some((value) => value === false)) return "false"; + return "unavailable"; +} + +function safeEpisodeId(value) { + const id = String(value ?? "").trim(); + if (!SAFE_EPISODE_ID_RE.test(id)) { + throw new Error("native learning review requires privacy-safe Task Episode ids"); + } + return id; +} + +function safeRoute(value) { + const route = String(value ?? "").trim().toLowerCase(); + return SAFE_ROUTE_RE.test(route) ? route : ""; +} + +function safeTarget(value) { + const target = String(value ?? "").trim().toLowerCase(); + return SAFE_TARGET_KEY_RE.test(target) ? target : ""; +} + +function safeCheck(value) { + const check = String(value ?? "").trim().toLowerCase(); + return SAFE_CHECK_ID_RE.test(check) ? check : ""; +} + +function safeEvidenceIdentity(reference) { + const fingerprint = String(reference?.fingerprint ?? reference?.publicFingerprint ?? "").trim().toLowerCase(); + if (SAFE_EVIDENCE_FINGERPRINT_RE.test(fingerprint)) return "fingerprint:" + fingerprint; + const id = String(reference?.id ?? "").trim(); + if (!SAFE_EVIDENCE_ID_RE.test(id) || PRIVATE_EVIDENCE_ID_RE.test(id)) return ""; + return "id:" + id; +} + +function publicEvidenceRows(episode) { + const references = [ + ...rows(episode?.evidenceRefs), + ...rows(episode?.changeSets).flatMap((change) => rows(change?.evidenceRefs)), + ...rows(episode?.validationSets).flatMap((validation) => rows(validation?.evidenceRefs)), + ...rows(episode?.closure?.evidenceRefs), + ...rows(episode?.repair?.evidenceRefs), + ...rows(episode?.repair?.candidates).flatMap((candidate) => rows(candidate?.evidenceRefs)), + ...rows(episode?.learningSignals).flatMap((signal) => rows(signal?.evidenceRefs)), + ].map((reference) => ({ + kind: safeRoute(reference?.kind) || "evidence", + sourceIdentity: safeEvidenceIdentity(reference), + })).filter((reference) => reference.sourceIdentity); + const byKey = new Map(); + for (const reference of references) { + byKey.set(reference.kind + "\u0000" + reference.sourceIdentity, reference); + } + return [...byKey.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, reference]) => reference); +} + +function evidenceAliases(episodeId, episode, maximum) { + const all = publicEvidenceRows(episode); + const selected = all.slice(0, maximum).map((reference) => ({ + evidenceRef: `evidence-ref-${digest({ + episodeId, + kind: reference.kind, + sourceIdentity: reference.sourceIdentity, + }, 20)}`, + kind: reference.kind, + })); + return { + values: selected, + bindingDigest: digest({ episodeId, identities: all }), + totalCount: all.length, + truncated: all.length > selected.length, + }; +} + +function validationFailureObserved(episode) { + return rows(episode?.validationSets).some((validation) => FAILED_STATUS.has(String(validation?.status ?? "").toLowerCase())); +} + +function sameCheckRepairObserved(episode) { + return rows(episode?.repair?.candidates).some((candidate) => candidate?.sameCheck === true + && safeCheck(candidate?.failureCheckIdentity) + && safeCheck(candidate?.failureCheckIdentity) === safeCheck(candidate?.rerunCheckIdentity)); +} + +function protectiveState(episode) { + const signals = rows(episode?.learningSignals); + return observationState([ + episode?.protectiveIntervention, + episode?.protectiveInterventionObserved, + episode?.hookDenialProtective, + episode?.permissionSummary?.protectiveIntervention, + Number(episode?.permissionSummary?.protectedActions ?? 0) > 0 ? true : undefined, + Number(episode?.permissionSummary?.deniedActions ?? 0) > 0 ? true : undefined, + ...signals.map((signal) => signal?.frictionType === PROTECTIVE_FRICTION ? true : undefined), + ]); +} + +function correctionState(episode) { + return observationState([ + ...rows(episode?.learningSignals).map((signal) => { + const coverage = signal?.fieldEvidence?.userCorrection?.coverage; + if (coverage === "unavailable") return undefined; + if (coverage === "observed" && typeof signal?.userCorrection === "boolean") return signal.userCorrection; + const provenance = signal?.fieldProvenance?.userCorrection; + if (typeof signal?.userCorrection === "boolean" + && (signal.userCorrection === true || OBSERVED_PROVENANCE.has(provenance))) { + return signal.userCorrection; + } + return undefined; + }), + ]); +} + +function taskRoutes(episode) { + return uniqueSorted([ + safeRoute(episode?.taskRoute), + ...rows(episode?.lifecycleSignals).map((signal) => safeRoute(signal?.intent)), + ]); +} + +function targetRefs(episode) { + return uniqueSorted([ + ...rows(episode?.targetKeys).map(safeTarget), + ...rows(episode?.changeSets).flatMap((change) => rows(change?.targetKeys).map(safeTarget)), + ...rows(episode?.validationSets).flatMap((validation) => rows(validation?.targetKeys).map(safeTarget)), + ]); +} + +function checkRefs(episode) { + return uniqueSorted([ + ...rows(episode?.validationSets).map((validation) => safeCheck(validation?.checkIdentity)), + ...rows(episode?.repair?.candidates).flatMap((candidate) => [ + safeCheck(candidate?.failureCheckIdentity), + safeCheck(candidate?.rerunCheckIdentity), + ]), + ]); +} + +function projectedEpisodeFact(episode, limits) { + const episodeRef = safeEpisodeId(episode?.id); + const evidence = evidenceAliases(episodeRef, episode, limits.maxEvidenceRefsPerEpisode); + const userCorrection = correctionState(episode); + const sameCheckRepair = sameCheckRepairObserved(episode); + const validationFailure = validationFailureObserved(episode); + const frictionKinds = uniqueSorted([ + userCorrection === "true" ? "explicit-user-correction" : "", + sameCheckRepair ? "same-check-repair" : "", + validationFailure ? "validation-failure" : "", + ]); + return { + episodeRef, + taskRoutes: taskRoutes(episode), + targetRefs: targetRefs(episode), + checkRefs: checkRefs(episode), + facts: { + userCorrection, + protectiveIntervention: protectiveState(episode), + sameCheckRepair: sameCheckRepair ? "true" : "false", + validationFailure: validationFailure ? "true" : "false", + }, + frictionKinds, + evidenceRefs: evidence.values.map((row) => row.evidenceRef), + evidenceCoverage: { + status: evidence.totalCount === 0 ? "unavailable" : evidence.truncated ? "truncated" : "bounded", + includedCount: evidence.values.length, + totalCount: evidence.totalCount, + identitySetDigest: evidence.bindingDigest, + }, + }; +} + +function intersection(left, right) { + const rightSet = new Set(right); + return left.filter((value) => rightSet.has(value)); +} + +function comparableGroup(left, right) { + if (left.facts.protectiveIntervention === "true" || right.facts.protectiveIntervention === "true") return null; + if (left.evidenceRefs.length === 0 || right.evidenceRefs.length === 0) return null; + const sharedTaskRoutes = intersection(left.taskRoutes, right.taskRoutes); + const sharedTargets = intersection(left.targetRefs, right.targetRefs); + const sharedChecks = intersection(left.checkRefs, right.checkRefs); + if (sharedTaskRoutes.length === 0 && sharedTargets.length === 0 && sharedChecks.length === 0) return null; + + const allowedPatternIds = []; + const correctionShape = uniqueSorted([ + left.facts.userCorrection === "true" && right.facts.userCorrection === "true" ? "explicit-user-correction" : "", + left.facts.sameCheckRepair === "true" && right.facts.sameCheckRepair === "true" ? "same-check-repair" : "", + ]); + if (correctionShape.length > 0) { + allowedPatternIds.push("recurring-correction"); + } + if (allowedPatternIds.length === 0) return null; + + const reasonCodes = uniqueSorted([ + sharedTaskRoutes.length > 0 ? "same-task-route" : "", + sharedTargets.length > 0 ? "same-target" : "", + sharedChecks.length > 0 ? "same-check" : "", + left.facts.userCorrection === "true" && right.facts.userCorrection === "true" ? "explicit-user-correction" : "", + left.facts.sameCheckRepair === "true" && right.facts.sameCheckRepair === "true" ? "same-check-repair" : "", + ]); + const episodeRefs = [left.episodeRef, right.episodeRef].sort(); + const groupBasis = { + allowedPatternIds: allowedPatternIds.sort(), + sharedTaskRoutes, + sharedTargets, + sharedChecks, + correctionShape, + }; + return { + groupKey: digest(groupBasis, 20), + episodeRefs, + allowedPatternIds: groupBasis.allowedPatternIds, + reasonCodes, + sharedIdentity: { + taskRoutes: sharedTaskRoutes, + targetRefs: sharedTargets, + checkRefs: sharedChecks, + }, + correctionShape, + evidenceRefs: uniqueSorted([...left.evidenceRefs, ...right.evidenceRefs]), + evidenceOwnership: Object.fromEntries(episodeRefs.map((episodeRef) => { + const fact = episodeRef === left.episodeRef ? left : right; + return [episodeRef, [...fact.evidenceRefs]]; + })), + }; +} + +function screeningGroups(episodeFacts) { + const pairGroups = []; + for (let left = 0; left < episodeFacts.length; left += 1) { + for (let right = left + 1; right < episodeFacts.length; right += 1) { + const group = comparableGroup(episodeFacts[left], episodeFacts[right]); + if (group) pairGroups.push(group); + } + } + const byKey = new Map(); + for (const pair of pairGroups) { + const merged = byKey.get(pair.groupKey) ?? { + groupKey: pair.groupKey, + episodeRefs: [], + allowedPatternIds: [...pair.allowedPatternIds], + reasonCodes: [], + sharedIdentity: pair.sharedIdentity, + correctionShape: [...pair.correctionShape], + evidenceRefs: [], + evidenceOwnership: {}, + }; + merged.episodeRefs = uniqueSorted([...merged.episodeRefs, ...pair.episodeRefs]); + merged.reasonCodes = uniqueSorted([...merged.reasonCodes, ...pair.reasonCodes]); + merged.evidenceRefs = uniqueSorted([...merged.evidenceRefs, ...pair.evidenceRefs]); + for (const [episodeRef, evidenceRefs] of Object.entries(pair.evidenceOwnership)) { + merged.evidenceOwnership[episodeRef] = uniqueSorted([ + ...rows(merged.evidenceOwnership[episodeRef]), + ...evidenceRefs, + ]); + } + byKey.set(pair.groupKey, merged); + } + return [...byKey.values()].map(({ groupKey, ...group }) => ({ + groupRef: `learning-group:${digest({ groupKey, episodeRefs: group.episodeRefs }, 20)}`, + patternSignature: `native-${groupKey}`, + ...group, + evidenceOwnership: Object.fromEntries( + Object.entries(group.evidenceOwnership).sort(([left], [right]) => left.localeCompare(right)), + ), + })).sort((left, right) => left.groupRef.localeCompare(right.groupRef)); +} + +function coverageFor(allEpisodeCount, episodeFacts, allGroups, selectedGroups) { + const episodeTruncated = allEpisodeCount > episodeFacts.length; + const groupTruncated = allGroups.length > selectedGroups.length; + const evidenceTruncated = episodeFacts.some((episode) => episode.evidenceCoverage.status === "truncated"); + const missingEvidenceEpisodeCount = episodeFacts.filter((episode) => episode.evidenceCoverage.status === "unavailable").length; + const protectiveEpisodeCount = episodeFacts.filter((episode) => episode.facts.protectiveIntervention === "true").length; + const status = episodeTruncated || groupTruncated || evidenceTruncated + ? "partial-truncated" + : selectedGroups.length > 0 + ? "candidate-groups-present" + : episodeFacts.length < 2 + ? "insufficient-episodes" + : missingEvidenceEpisodeCount > 0 + ? "insufficient-evidence" + : "insufficient-comparability"; + return { + status, + inputEpisodeCount: allEpisodeCount, + includedEpisodeCount: episodeFacts.length, + candidateGroupCount: selectedGroups.length, + totalCandidateGroupCount: allGroups.length, + protectiveEpisodeCount, + missingEvidenceEpisodeCount, + truncated: episodeTruncated || groupTruncated || evidenceTruncated, + reasonCodes: uniqueSorted([ + episodeTruncated ? "episode-limit-reached" : "", + groupTruncated ? "group-limit-reached" : "", + evidenceTruncated ? "evidence-limit-reached" : "", + missingEvidenceEpisodeCount > 0 ? "missing-evidence" : "", + protectiveEpisodeCount > 0 ? "protective-intervention-excluded" : "", + selectedGroups.length === 0 && episodeFacts.length >= 2 ? "no-comparable-group" : "", + ]), + }; +} + +function packetPayload(packet) { + const { packetDigest: _packetDigest, ...payload } = packet ?? {}; + return payload; +} + +export function nativeLearningReviewPacketDigest(packet) { + return digest(packetPayload(packet)); +} + +export function buildNativeLearningReviewPacket({ episodes = [], limits: suppliedLimits = {} } = {}) { + const limits = normalizedLimits(suppliedLimits); + const byId = new Map(); + for (const episode of rows(episodes)) { + const episodeId = safeEpisodeId(episode?.id); + const candidates = byId.get(episodeId) ?? []; + candidates.push(episode); + byId.set(episodeId, candidates); + } + const allEpisodeCount = byId.size; + const episodeFacts = [...byId.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, limits.maxEpisodes) + .map(([, candidates]) => { + const projections = candidates.map((candidate) => projectedEpisodeFact(candidate, limits)); + const distinct = new Map(projections.map((candidate) => [JSON.stringify(canonicalValue(candidate)), candidate])); + if (distinct.size !== 1) { + throw new Error("native learning review rejects conflicting duplicate Task Episode projections"); + } + return distinct.values().next().value; + }) + .sort((left, right) => left.episodeRef.localeCompare(right.episodeRef)); + const allGroups = screeningGroups(episodeFacts); + const groups = allGroups.slice(0, limits.maxGroups); + const safeSourceProjection = { episodeFacts, groups, limits }; + const packet = { + schemaVersion: NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION, + kind: NATIVE_LEARNING_REVIEW_KIND, + sourceDigest: digest(safeSourceProjection), + limits, + allowed: { + decisions: [...NATIVE_LEARNING_REVIEW_DECISIONS], + patternIds: [...NATIVE_LEARNING_PATTERN_IDS], + matchReasonCodes: [...NATIVE_LEARNING_MATCH_REASON_CODES], + abstainReasonCodes: [...NATIVE_LEARNING_ABSTAIN_REASON_CODES], + }, + episodeFacts, + groups, + coverage: coverageFor(allEpisodeCount, episodeFacts, allGroups, groups), + }; + packet.packetDigest = nativeLearningReviewPacketDigest(packet); + return packet; +} + +export function validateNativeLearningReviewPacket({ episodes = [], packet } = {}) { + const errors = []; + if (packet?.schemaVersion !== NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION) { + errors.push(`native learning review packet schemaVersion must be ${NATIVE_LEARNING_REVIEW_PACKET_SCHEMA_VERSION}`); + } + if (packet?.kind !== NATIVE_LEARNING_REVIEW_KIND) errors.push("native learning review packet kind is invalid"); + if (packet?.packetDigest !== nativeLearningReviewPacketDigest(packet)) { + errors.push("native learning review packet digest does not match its content"); + } + let expected; + try { + expected = buildNativeLearningReviewPacket({ episodes, limits: packet?.limits }); + } catch (error) { + errors.push(error.message); + return [...new Set(errors)]; + } + for (const field of ["sourceDigest", "limits", "allowed", "episodeFacts", "groups", "coverage"]) { + if (digest(packet?.[field]) !== digest(expected[field])) { + errors.push(`native learning review packet ${field} does not match the current privacy-safe source projection`); + } + } + return [...new Set(errors)]; +} diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 69adf83..35a4b8a 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -182,6 +182,8 @@ const required = [ "package/scripts/harness-analysis/episode-evidence-review.mjs", "package/scripts/harness-analysis/report-review-packet.mjs", "package/scripts/harness-analysis/report-source.mjs", + "package/scripts/harness-analysis/learning-loop-candidates.mjs", + "package/scripts/harness-analysis/learning-loop-review-packet.mjs", "package/scripts/harness-analysis/record-fix-output.mjs", "package/scripts/harness-analysis/preview-support/canvas-transform.mjs", "package/scripts/session-analysis/episode-facts.mjs", @@ -308,6 +310,8 @@ const requiredBundleEntries = [ "scripts/harness-analysis/episode-evidence-review.mjs", "scripts/harness-analysis/report-review-packet.mjs", "scripts/harness-analysis/report-source.mjs", + "scripts/harness-analysis/learning-loop-candidates.mjs", + "scripts/harness-analysis/learning-loop-review-packet.mjs", "scripts/harness-analysis/record-fix-output.mjs", "scripts/harness-analysis/preview-support/canvas-transform.mjs", "scripts/session-analysis/episode-facts.mjs", diff --git a/scripts/session-analysis/episode-contract.mjs b/scripts/session-analysis/episode-contract.mjs index 1881bcd..82e66e1 100644 --- a/scripts/session-analysis/episode-contract.mjs +++ b/scripts/session-analysis/episode-contract.mjs @@ -580,7 +580,6 @@ function learningSignalsFor(episode) { if (!patternId) return []; const signal = { patternId, - userCorrection: source.userCorrection === true, assetLoaded: source.assetLoaded === true, assetChanged: source.assetChanged === true, assetRelevant: source.assetRelevant === true, @@ -592,6 +591,10 @@ function learningSignalsFor(episode) { tokens: source.tokens ?? 0, evidenceRefs: eventEvidenceRefs(event), }; + if (typeof source.userCorrection === "boolean") { + signal.userCorrection = source.userCorrection; + signal.fieldProvenance = { userCorrection: "host-observed" }; + } const asset = safeLearningAsset(source.asset); if (asset) signal.asset = asset; const textFields = ["normalizedSignature", "taskFamily", "repoArea", "changeType", "frictionType", "validationResult", "deliveryResult", "harnessVersion"]; @@ -601,12 +604,15 @@ function learningSignalsFor(episode) { if (value) signal[field] = value; } if (source.fieldProvenance && typeof source.fieldProvenance === "object" && !Array.isArray(source.fieldProvenance)) { - signal.fieldProvenance = Object.fromEntries(learningFields.flatMap((field) => { + signal.fieldProvenance = { + ...(signal.fieldProvenance ?? {}), + ...Object.fromEntries(learningFields.flatMap((field) => { const provenance = source.fieldProvenance[field]; return ["host-observed", "deterministic-derived", "ai-reviewed"].includes(provenance) ? [[field, provenance]] : []; - })); + })), + }; } return [signal]; }); @@ -711,6 +717,8 @@ function publicEpisode(episode, platform) { const rawValidationSets = summarizeValidationSets(episode); const closure = closureFor(episode, changeSets, rawValidationSets); const permissionSummary = permissionBoundarySummary(episode.events); + const protectiveInterventionObserved = episode.events.some((event) => + classifyExecutionSignal(event).kind.startsWith("protective-")); return { id: episode.id, sessionCount: episode.sessionIds.size, @@ -730,6 +738,7 @@ function publicEpisode(episode, platform) { toolCalls: episode.events.filter((event) => event?.toolName || event?.functionCallName).length, lifecycleSignals: lifecycleSignalsFor(episode, platform), ...(permissionSummary ? { permissionSummary } : {}), + ...(protectiveInterventionObserved ? { protectiveInterventionObserved: true } : {}), learningSignals: learningSignalsFor(episode), evidenceRefs: episode.events.flatMap(eventEvidenceRefs), }; diff --git a/test/native-learning-candidate-review.test.mjs b/test/native-learning-candidate-review.test.mjs new file mode 100644 index 0000000..808fafb --- /dev/null +++ b/test/native-learning-candidate-review.test.mjs @@ -0,0 +1,528 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyNativeLearningCandidateReview, + buildLearningLoopReview, + buildNativeLearningReviewPacket, + validateNativeLearningCandidateReview, + validateLearningLoopReview, +} from "../scripts/harness-analysis/learning-loop-candidates.mjs"; +import { buildTaskEpisodes } from "../scripts/session-analysis/episode-contract.mjs"; + +function event({ + sessionId, + timestamp, + toolName, + filePath, + commandText, + targetPaths, + success, + evidenceId, + ...rest +}) { + return { + sessionId, + timestamp, + type: toolName ? "tool" : "user", + toolName, + filePath, + commandText, + validationCategory: commandText ? "node --test" : undefined, + targetPaths, + success, + evidenceRef: { kind: "fixture", id: evidenceId }, + ...rest, + }; +} + +function repairEvents(sessionId, minute, suffix = "a") { + const base = "2026-08-01T10:" + String(minute).padStart(2, "0"); + const commandText = "node --test test/shared.test.mjs"; + return [ + event({ + sessionId, + timestamp: base + ":00.000Z", + toolName: "Bash", + commandText, + targetPaths: ["src/shared.mjs"], + success: false, + evidenceId: suffix + "-failure", + }), + event({ + sessionId, + timestamp: base + ":01.000Z", + toolName: "Edit", + filePath: "src/shared.mjs", + evidenceId: suffix + "-edit", + }), + event({ + sessionId, + timestamp: base + ":02.000Z", + toolName: "Bash", + commandText, + targetPaths: ["src/shared.mjs"], + success: true, + evidenceId: suffix + "-rerun", + }), + ]; +} + +function ordinaryRepairEpisodes() { + const episodes = buildTaskEpisodes([ + ...repairEvents("session-a", 0, "a"), + ...repairEvents("session-b", 5, "b"), + ]).episodes; + assert.equal(episodes.length, 2); + assert.equal(episodes.every((episode) => episode.learningSignals.length === 0), true); + assert.equal(episodes.every((episode) => episode.repair.candidates.some((candidate) => candidate.sameCheck)), true); + return episodes; +} + +function matchingReview(packet, overrides = {}) { + return { + schemaVersion: 1, + sourceDigest: packet.sourceDigest, + packetDigest: packet.packetDigest, + decisions: packet.groups.map((group) => ({ + groupRef: group.groupRef, + decision: "match", + patternId: "recurring-correction", + episodeRefs: [...group.episodeRefs], + evidenceRefs: [...group.evidenceRefs], + reasonCodes: [...group.reasonCodes], + ...overrides, + })), + }; +} + +test("ordinary failure-edit-same-check-rerun Episodes produce a reviewed recurring-correction opportunity", () => { + const episodes = ordinaryRepairEpisodes(); + const packet = buildNativeLearningReviewPacket({ episodes }); + assert.equal(packet.groups.length, 1); + assert.deepEqual(packet.groups[0].allowedPatternIds, ["recurring-correction"]); + assert.ok(packet.groups[0].reasonCodes.includes("same-check")); + assert.ok(packet.groups[0].reasonCodes.includes("same-check-repair")); + + const review = matchingReview(packet); + const applied = applyNativeLearningCandidateReview({ episodes, packet, review }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.matches.length, 1); + assert.equal(applied.result.matches[0].claimType, "opportunity"); + assert.equal(applied.result.learningLoop.candidates.length, 1); + assert.equal(applied.result.learningLoop.candidates[0].patternId, "recurring-correction"); + assert.equal(applied.result.learningLoop.candidates[0].claimType, "opportunity"); + assert.deepEqual( + validateLearningLoopReview(applied.result.learningLoop, { episodeIds: episodes.map((episode) => episode.id) }), + [], + ); + assert.deepEqual( + applied.result.learningLoop.candidates[0].sourceEpisodes.slice().sort(), + episodes.map((episode) => episode.id).sort(), + ); + assert.equal( + applied.result.learningLoop.episodeRecords.every((episode) => + episode.signals[0].fieldEvidence.frictionType.provenance === "ai-reviewed"), + true, + ); + assert.equal( + applied.result.learningLoop.episodeRecords.every((episode) => + episode.signals[0].fieldEvidence.normalizedSignature.provenance === "deterministic-derived" + && episode.signals[0].fieldEvidence.userCorrection.coverage === "unavailable"), + true, + ); + for (const episode of applied.result.learningLoop.episodeRecords) { + const owned = new Set(packet.groups[0].evidenceOwnership[episode.episodeId]); + assert.equal(episode.signals[0].evidenceRefs.every((reference) => owned.has(reference.id)), true); + } + assert.equal(applied.result.learningLoop.candidates.some((candidate) => candidate.claimType === "effectiveness"), false); + assert.equal(applied.result.learningLoop.coverage.effectiveness, "pending-no-later-window"); +}); + +test("three comparable Episodes merge into one stable pattern candidate", () => { + const twoEpisodes = ordinaryRepairEpisodes(); + const threeEpisodes = buildTaskEpisodes([ + ...repairEvents("session-a", 0, "a"), + ...repairEvents("session-b", 5, "b"), + ...repairEvents("session-c", 10, "c"), + ]).episodes; + const twoPacket = buildNativeLearningReviewPacket({ episodes: twoEpisodes }); + const threePacket = buildNativeLearningReviewPacket({ episodes: threeEpisodes }); + assert.equal(threePacket.groups.length, 1); + assert.equal(threePacket.groups[0].episodeRefs.length, 3); + assert.equal(threePacket.groups[0].patternSignature, twoPacket.groups[0].patternSignature); + assert.deepEqual( + buildNativeLearningReviewPacket({ episodes: structuredClone(threeEpisodes).reverse() }), + threePacket, + ); + const applied = applyNativeLearningCandidateReview({ + episodes: threeEpisodes, + packet: threePacket, + review: matchingReview(threePacket), + }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.learningLoop.candidates.length, 1); + assert.equal(applied.result.learningLoop.candidates[0].sourceEpisodes.length, 3); + assert.equal( + applied.result.learningLoop.candidates[0].normalizedSignature, + twoPacket.groups[0].patternSignature, + ); +}); + +test("same failure without a shared task, target, or check identity is a hard negative", () => { + const left = repairEvents("session-left", 0, "left"); + const right = repairEvents("session-right", 5, "right").map((row) => ({ + ...row, + commandText: row.commandText?.replace("shared", "other"), + targetPaths: row.targetPaths?.map((target) => target.replace("shared", "other")), + filePath: row.filePath?.replace("shared", "other"), + })); + const episodes = buildTaskEpisodes([...left, ...right]).episodes; + const packet = buildNativeLearningReviewPacket({ episodes }); + assert.equal(packet.groups.length, 0); + assert.equal(packet.coverage.status, "insufficient-comparability"); + assert.ok(packet.coverage.reasonCodes.includes("no-comparable-group")); +}); + +test("single Episodes and protective permission or Hook denials cannot form native friction groups", () => { + const episodes = ordinaryRepairEpisodes(); + const single = buildNativeLearningReviewPacket({ episodes: [episodes[0]] }); + assert.equal(single.groups.length, 0); + assert.equal(single.coverage.status, "insufficient-episodes"); + + const protectedEpisodes = structuredClone(episodes); + protectedEpisodes[0].permissionSummary = { + denied: 1, + protectedActions: 1, + evidenceRefs: [{ kind: "permission", id: "bounded-denial" }], + }; + const packet = buildNativeLearningReviewPacket({ episodes: protectedEpisodes }); + assert.equal(packet.groups.length, 0); + assert.equal(packet.coverage.protectiveEpisodeCount, 1); + assert.ok(packet.coverage.reasonCodes.includes("protective-intervention-excluded")); + + const hookEpisodes = buildTaskEpisodes([ + ...repairEvents("session-hook", 0, "hook"), + event({ + sessionId: "session-hook", + timestamp: "2026-08-01T10:00:03.000Z", + toolName: "Guardrail", + success: false, + hookDecision: "blocked", + protectionOutcome: "blocked", + evidenceId: "hook-blocked", + }), + ...repairEvents("session-peer", 5, "peer"), + ]).episodes; + assert.equal(hookEpisodes.some((episode) => episode.protectiveInterventionObserved === true), true); + const hookPacket = buildNativeLearningReviewPacket({ episodes: hookEpisodes }); + assert.equal(hookPacket.groups.length, 0); + assert.equal(hookPacket.coverage.protectiveEpisodeCount, 1); +}); + +test("review binding rejects invented refs, unknown patterns, missing decisions, and unowned evidence", () => { + const episodes = ordinaryRepairEpisodes(); + const packet = buildNativeLearningReviewPacket({ episodes }); + const valid = matchingReview(packet); + assert.deepEqual(validateNativeLearningCandidateReview({ episodes, packet, review: valid }), []); + + const inventedEvidence = structuredClone(valid); + inventedEvidence.decisions[0].evidenceRefs.push("evidence-ref:invented"); + assert.ok(validateNativeLearningCandidateReview({ episodes, packet, review: inventedEvidence }) + .some((error) => error.includes("outside the packet allowlist"))); + + const unknownPattern = structuredClone(valid); + unknownPattern.decisions[0].patternId = "repeated-rediscovery"; + assert.ok(validateNativeLearningCandidateReview({ episodes, packet, review: unknownPattern }) + .some((error) => error.includes("not allowed"))); + + const missingDecision = { ...valid, decisions: [] }; + assert.ok(validateNativeLearningCandidateReview({ episodes, packet, review: missingDecision }) + .some((error) => error.includes("exactly one decision"))); + + const oneEpisodeEvidence = structuredClone(valid); + const group = packet.groups[0]; + oneEpisodeEvidence.decisions[0].evidenceRefs = [...group.evidenceOwnership[group.episodeRefs[0]]]; + assert.ok(validateNativeLearningCandidateReview({ episodes, packet, review: oneEpisodeEvidence }) + .some((error) => error.includes("requires evidence owned by"))); +}); + +test("an explicit abstention remains evidence-safe and does not create a candidate", () => { + const episodes = ordinaryRepairEpisodes(); + const packet = buildNativeLearningReviewPacket({ episodes }); + const review = { + schemaVersion: 1, + sourceDigest: packet.sourceDigest, + packetDigest: packet.packetDigest, + decisions: [{ + groupRef: packet.groups[0].groupRef, + decision: "abstain", + reasonCodes: ["insufficient-comparability"], + }], + }; + const applied = applyNativeLearningCandidateReview({ episodes, packet, review }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.matches.length, 0); + assert.equal(applied.result.abstentions.length, 1); + assert.equal(applied.result.learningLoop.candidates.length, 0); + assert.equal(applied.result.coverage.abstainedGroupCount, 1); + + const nonArrayRefs = structuredClone(review); + nonArrayRefs.decisions[0].episodeRefs = ""; + nonArrayRefs.decisions[0].evidenceRefs = "evidence-ref:not-an-array"; + const errors = validateNativeLearningCandidateReview({ episodes, packet, review: nonArrayRefs }); + assert.ok(errors.some((error) => error.includes("episodeRefs must be an array"))); + assert.ok(errors.some((error) => error.includes("evidenceRefs must be an array"))); +}); + +test("evidence aliases bind safe source ids without exposing them and stale reviews fail closed", () => { + const episodes = ordinaryRepairEpisodes(); + const packet = buildNativeLearningReviewPacket({ episodes }); + const review = matchingReview(packet); + const changed = structuredClone(episodes); + const replaceEvidenceId = (value) => { + if (Array.isArray(value)) { + for (const item of value) replaceEvidenceId(item); + return; + } + if (!value || typeof value !== "object") return; + if (value.kind === "fixture" && value.id === "a-failure") value.id = "a-failure-v2"; + for (const child of Object.values(value)) replaceEvidenceId(child); + }; + replaceEvidenceId(changed[0]); + const changedPacket = buildNativeLearningReviewPacket({ episodes: changed }); + assert.notEqual(changedPacket.sourceDigest, packet.sourceDigest); + assert.notDeepEqual(changedPacket.episodeFacts, packet.episodeFacts); + const stale = applyNativeLearningCandidateReview({ episodes: changed, packet, review }); + assert.equal(stale.result, null); + assert.ok(stale.errors.some((error) => error.includes("privacy-safe source projection"))); + const serialized = JSON.stringify(changedPacket); + assert.equal(serialized.includes("a-failure"), false); + assert.equal(serialized.includes("a-failure-v2"), false); +}); + +test("evidence binding covers safe identities beyond the displayed evidence limit", () => { + const episodes = ordinaryRepairEpisodes(); + episodes[0].evidenceRefs = []; + for (const change of episodes[0].changeSets) change.evidenceRefs = []; + for (const validation of episodes[0].validationSets) validation.evidenceRefs = []; + episodes[0].closure.evidenceRefs = []; + episodes[0].repair.evidenceRefs = []; + for (const candidate of episodes[0].repair.candidates) candidate.evidenceRefs = []; + episodes[0].learningSignals = []; + for (let index = 1; index <= 13; index += 1) { + episodes[0].evidenceRefs.push({ + kind: "fixture", + id: `zz-overflow-${String(index).padStart(2, "0")}`, + }); + } + const packet = buildNativeLearningReviewPacket({ episodes }); + const review = matchingReview(packet); + const changed = structuredClone(episodes); + changed[0].evidenceRefs.find((reference) => reference.id === "zz-overflow-13").id = "zz-overflow-99"; + const changedPacket = buildNativeLearningReviewPacket({ episodes: changed }); + + assert.equal(packet.episodeFacts[0].evidenceCoverage.includedCount, 12); + assert.equal(packet.episodeFacts[0].evidenceCoverage.totalCount, 13); + assert.equal(packet.episodeFacts[0].evidenceCoverage.status, "truncated"); + assert.deepEqual(changedPacket.episodeFacts[0].evidenceRefs, packet.episodeFacts[0].evidenceRefs); + assert.notEqual( + changedPacket.episodeFacts[0].evidenceCoverage.identitySetDigest, + packet.episodeFacts[0].evidenceCoverage.identitySetDigest, + ); + assert.notEqual(changedPacket.sourceDigest, packet.sourceDigest); + const stale = applyNativeLearningCandidateReview({ episodes: changed, packet, review }); + assert.equal(stale.result, null); + assert.ok(stale.errors.some((error) => error.includes("privacy-safe source projection"))); + assert.equal(JSON.stringify(changedPacket).includes("zz-overflow-99"), false); +}); + +test("mixed explicit and repair corrections keep separate friction bases and member observations", () => { + const episodes = buildTaskEpisodes([ + ...repairEvents("session-a", 0, "a"), + ...repairEvents("session-b", 5, "b"), + ...repairEvents("session-c", 10, "c"), + ]).episodes; + episodes[0].repair.candidates = []; + episodes[0].repair.evidenceRefs = []; + for (const episode of episodes.slice(0, 2)) { + episode.learningSignals = [{ + userCorrection: true, + fieldProvenance: { userCorrection: "host-observed" }, + evidenceRefs: [{ kind: "fixture", id: episode.id + "-correction" }], + }]; + } + const packet = buildNativeLearningReviewPacket({ episodes }); + assert.equal(packet.groups.length, 2); + assert.deepEqual( + packet.groups.map((group) => JSON.stringify(group.correctionShape)).sort(), + [JSON.stringify(["explicit-user-correction"]), JSON.stringify(["same-check-repair"])].sort(), + ); + assert.notEqual(packet.groups[0].patternSignature, packet.groups[1].patternSignature); + const applied = applyNativeLearningCandidateReview({ + episodes, + packet, + review: matchingReview(packet), + }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.learningLoop.candidates.length, 2); + const byEpisode = new Map(applied.result.learningLoop.episodeRecords.map((episode) => [episode.episodeId, episode])); + const cSignals = byEpisode.get(episodes[2].id).signals.filter((signal) => signal.patternId === "recurring-correction"); + assert.equal(cSignals.length, 1); + assert.equal(cSignals[0].frictionType, "same-check-repair"); + assert.equal(cSignals[0].fieldEvidence.userCorrection.coverage, "unavailable"); + const bSignals = byEpisode.get(episodes[1].id).signals.filter((signal) => signal.patternId === "recurring-correction"); + assert.equal(bSignals.length, 2); + assert.equal(bSignals.some((signal) => signal.fieldEvidence.userCorrection.coverage === "observed"), true); + assert.equal(bSignals.some((signal) => signal.fieldEvidence.userCorrection.coverage === "unavailable"), true); +}); + +test("Episode and evidence reorder are stable, exact duplicates dedupe, and conflicting duplicates fail closed", () => { + const episodes = ordinaryRepairEpisodes(); + const baseline = buildNativeLearningReviewPacket({ episodes }); + const reordered = structuredClone(episodes).reverse(); + for (const episode of reordered) { + episode.evidenceRefs = [...episode.evidenceRefs].reverse(); + episode.evidenceRefs.push(...episode.evidenceRefs); + for (const candidate of episode.repair.candidates) candidate.evidenceRefs.reverse(); + } + assert.deepEqual(buildNativeLearningReviewPacket({ episodes: reordered }), baseline); + assert.deepEqual(buildNativeLearningReviewPacket({ episodes: [...episodes, structuredClone(episodes[0])] }), baseline); + + const conflicting = structuredClone(episodes[0]); + conflicting.permissionSummary = { protectedActions: 1, denied: 1, evidenceRefs: [] }; + assert.throws( + () => buildNativeLearningReviewPacket({ episodes: [...episodes, conflicting] }), + /conflicting duplicate Task Episode projections/u, + ); +}); + +test("missing and false correction observations remain distinct tri-state facts", () => { + const episodes = ordinaryRepairEpisodes(); + const missing = buildNativeLearningReviewPacket({ episodes }); + assert.equal(missing.episodeFacts[0].facts.userCorrection, "unavailable"); + + const explicitFalse = structuredClone(episodes); + for (const episode of explicitFalse) { + episode.learningSignals = [{ + userCorrection: false, + fieldProvenance: { userCorrection: "host-observed" }, + }]; + } + const falsePacket = buildNativeLearningReviewPacket({ episodes: explicitFalse }); + assert.equal(falsePacket.episodeFacts[0].facts.userCorrection, "false"); + + const legacyDefaultFalse = structuredClone(episodes); + for (const episode of legacyDefaultFalse) episode.learningSignals = [{ userCorrection: false }]; + const legacyPacket = buildNativeLearningReviewPacket({ episodes: legacyDefaultFalse }); + assert.equal(legacyPacket.episodeFacts[0].facts.userCorrection, "unavailable"); + + const explicitTrue = structuredClone(episodes); + for (const episode of explicitTrue) episode.learningSignals = [{ userCorrection: true }]; + const truePacket = buildNativeLearningReviewPacket({ episodes: explicitTrue }); + assert.equal(truePacket.episodeFacts[0].facts.userCorrection, "true"); +}); + +test("raw privacy sentinels do not affect or leak through packet and apply output", () => { + const episodes = ordinaryRepairEpisodes(); + const baseline = buildNativeLearningReviewPacket({ episodes }); + const privateEpisodes = structuredClone(episodes); + const sentinels = [ + "C:\\Users\\alice\\private\\session.jsonl", + "/Users/alice/private/session.jsonl", + "/home/alice/private/session.jsonl", + "\\\\server\\alice\\private\\session.jsonl", + "alice@example.com", + "session-secret-123", + "https://alice:password@example.com/private", + ]; + privateEpisodes[0].rawPrompt = sentinels.join(" "); + privateEpisodes[0].transcript = sentinels; + privateEpisodes[0].sessionId = "session-secret-123"; + assert.deepEqual(buildNativeLearningReviewPacket({ episodes: privateEpisodes }), baseline); + privateEpisodes[0].evidenceRefs = [{ + kind: "fixture", + id: "C:\\Users\\alice\\private\\session.jsonl", + }, ...privateEpisodes[0].evidenceRefs]; + const packet = buildNativeLearningReviewPacket({ episodes: privateEpisodes }); + assert.deepEqual(packet, baseline); + const serializedPacket = JSON.stringify(packet); + for (const sentinel of sentinels) assert.equal(serializedPacket.includes(sentinel), false); + + const applied = applyNativeLearningCandidateReview({ + episodes: privateEpisodes, + packet, + review: matchingReview(packet), + }); + assert.deepEqual(applied.errors, []); + const serializedResult = JSON.stringify(applied.result); + for (const sentinel of sentinels) assert.equal(serializedResult.includes(sentinel), false); +}); + +test("truncated evidence remains partial instead of checked-clean", () => { + const episodes = ordinaryRepairEpisodes(); + const packet = buildNativeLearningReviewPacket({ + episodes, + limits: { maxEvidenceRefsPerEpisode: 1 }, + }); + assert.equal(packet.coverage.truncated, true); + assert.equal(packet.coverage.status, "partial-truncated"); + assert.ok(packet.coverage.reasonCodes.includes("evidence-limit-reached")); + const applied = applyNativeLearningCandidateReview({ + episodes, + packet, + review: matchingReview(packet), + }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.coverage.status, "reviewed-partial"); +}); + +test("provider signals remain intact and equivalent native plus legacy matches dedupe", () => { + const episodes = ordinaryRepairEpisodes(); + for (const episode of episodes) { + episode.learningSignals = [{ + patternId: "recurring-correction", + normalizedSignature: "legacy-correction-route", + taskFamily: "debugging", + repoArea: "tests", + frictionType: "user-correction", + userCorrection: true, + evidenceRefs: [{ kind: "fixture", id: episode.id + "-legacy" }], + }]; + } + const packet = buildNativeLearningReviewPacket({ episodes }); + const applied = applyNativeLearningCandidateReview({ + episodes, + packet, + review: matchingReview(packet), + }); + assert.deepEqual(applied.errors, []); + assert.equal(applied.result.learningLoop.candidates.length, 1); + assert.equal(applied.result.learningLoop.candidates[0].normalizedSignature, "legacy-correction-route"); + assert.equal(applied.result.learningLoop.episodeRecords.every((episode) => episode.signals.length === 1), true); +}); + +test("historical flat learningPatternId events still build provider-supplied repeated-rediscovery candidates", () => { + const events = ["flat-a", "flat-b"].map((sessionId, index) => event({ + sessionId, + timestamp: "2026-08-01T11:0" + index + ":00.000Z", + toolName: "Read", + filePath: "src/shared.mjs", + evidenceId: sessionId, + learningPatternId: "repeated-rediscovery", + normalizedSignature: "legacy-discovery-route", + frictionType: "repeated-rediscovery", + })); + const episodes = buildTaskEpisodes(events).episodes; + assert.equal(episodes.every((episode) => !Object.hasOwn(episode.learningSignals[0], "userCorrection")), true); + const packet = buildNativeLearningReviewPacket({ episodes }); + assert.equal(packet.episodeFacts.every((episode) => episode.facts.userCorrection === "unavailable"), true); + const learningLoop = buildLearningLoopReview({ episodes }); + assert.equal(learningLoop.candidates.length, 1); + assert.equal(learningLoop.candidates[0].patternId, "repeated-rediscovery"); + assert.equal( + learningLoop.episodeRecords.every((episode) => + episode.signals[0].fieldEvidence.userCorrection.coverage === "unavailable"), + true, + ); +});