feat: freshness engine — keep code_verified claims honest as code changes - #96
Closed
divo12 wants to merge 21 commits into
Closed
feat: freshness engine — keep code_verified claims honest as code changes#96divo12 wants to merge 21 commits into
divo12 wants to merge 21 commits into
Conversation
…validation Phase 1 of anchor-drift auto-invalidation. Adds the invalidation_events table (audit trail for demoted claims), a domain type module, and a transactional repository writer that reuses the shared insertProposalRecords helper (extracted from createProposalRecords) so there is no duplicated insert SQL. Integrity relies on the claim primary key plus the single transaction. - libs/knowledge-graph/invalidation.ts: InvalidationEvent/Input + typed unions - schema.ts: invalidation_events table + indexes - repository.ts: applyAnchorInvalidation, listInvalidationEvents, extracted insertProposalRecords
…riendly) Phase 2 of anchor-drift auto-invalidation. scanDriftedClaims re-resolves every code_verified claim's anchors and flags a claim as drifted only when all of its anchors are broken (Option A). Uses one shared CodeAnchorResolver so shared files parse once, and records per-claim resolver failures without aborting the pass.
Phase 3 of anchor-drift auto-invalidation. buildAnchorInvalidation turns drifted claims into the writes that demote them: a rebuilt truth:unknown claim (broken anchors kept as evidence), cloned about/evidenced_by edges (preserving evidenced_by reasons), a supersedes edge, and one invalidation event each. Pure: edge ids are minted by normalizeProposal via an in-memory graph lookup, and outgoing edges come from a from_id index built once. Also consolidates the drift-status list into a single source of truth (invalidationResolverStatuses + guard) shared by drift.ts and the rebuild.
Phase 4 of anchor-drift auto-invalidation. Wires scan -> build -> apply:
re-verifies code_verified claims, demotes fully-drifted ones atomically via
applyAnchorInvalidation, embeds the rebuilt claims, and returns a consistent
envelope { memory_commit_id, invalidated[], errors }. No drift is a clean
no-op; the report-only auditCodeAnchors is untouched. Adds a small
libs/utils/git.ts helper for the HEAD SHA (best-effort provenance).
Phase 5 of anchor-drift auto-invalidation. The audit command now accepts --invalidate: it prints the diagnostic report first, then demotes fully drifted claims via invalidateDriftedAnchors and prints each demotion plus the memory commit. Report-only runs are unchanged (exit 1 on issues, the CI signal); --invalidate exits 0 on success. Flag parsing rejects unknown args with the usage string, matching the existing CLI convention.
Phase 6 of anchor-drift auto-invalidation. scripts/check-anchor-drift.js covers the happy path (rename -> demote, supersede, event row, history kept, edges + reasons cloned), Option A (multi-anchor survives a partial break, demoted only when all anchors break), and the never-demote cases (unchanged + unsupported-language). Uses a stub context builder so the suite never downloads an embedding model. Wired into npm test.
Author
|
@kushalpatil07 please review the PR , e2e testing with a coding agent will put results soon ; unit & e2e testing with CLI working |
TDD: span-level sha256 of the resolved anchor's source (whole file for file-only anchors), with graceful undefined on unreadable/escaping paths, plus statAnchorFile for the freshness prefilter.
…drift scan TDD: pure verdict rule — structural (all anchors broken) or content (a resolving anchor's stored span hash changed). scanDriftedClaims now delegates to it (structural-only via undefined baseline hashes), unchanged behavior.
…ndex) TDD: anchor_fingerprints table (file-indexed) as the fingerprint cache and reverse file->claims index; repository upsert (transactional INSERT OR REPLACE), batched fingerprintsForClaims read, and claimIdsForFiles reverse lookup. Data-access only.
TDD: KnowledgeGraphService.writeFingerprints resolves + span-hashes every code_verified claim's anchors and upserts the fingerprints after apply (best-effort per anchor). Wires check-span-hash + check-freshness into npm test.
Replace classifyFreshness's three positional parallel arrays with an explicit AnchorCheck[] (each anchor bundled with its current and stored span hash), and split the rule into named isStructurallyBroken / hasContentDrift predicates. Removes the awkward paired-undefined-arrays call in drift.ts and documents the 1-based span slicing in span-hash. Behavior unchanged; all checks pass.
- classifyFreshness: carry structurally-broken anchors through the content-drift verdict instead of dropping them (mixed-anchor claims). - span-hash: normalize CRLF/CR to LF before hashing so a cross-platform checkout doesn't report false content drift. - anchor_fingerprints: make symbol NOT NULL with a '' sentinel; a nullable column in the composite PK let INSERT OR REPLACE accumulate duplicate rows for file-only anchors (SQLite treats each NULL as distinct). - applyProposal: fingerprint writing is best-effort and no longer throws, so it can't fail an already-persisted apply. Adds regression checks for each.
Implement freshness engine with content fingerprinting and storage
* feat: add 'unknown' freshness state for undeterminable anchors A resolving anchor whose span can't be hashed right now (unreadable file or resolver error) now yields state:'unknown' instead of a false 'fresh', so the foreground can flag it as unverifiable rather than vouch for it. A missing baseline alone (readable span, no stored hash) stays fresh. Structural/content drift still take precedence. * feat: index fingerprints + build stat-prefiltered freshness checks New anchor-fingerprints module: indexFingerprintsByClaim groups stored rows by claim (O(1) lookup after one batched read), and freshnessChecks builds the AnchorCheck[] for a claim's resolved anchors with a cache-aside stat prefilter — reuse the stored hash when mtime+size are unchanged, otherwise re-hash the span live. * feat: compute per-claim freshness in the context builder (read-only) Add freshness: FreshnessVerdict to ClaimContextResult and a new attachFreshness step in the builder: one batched fingerprint read for all selected claims (no N+1), then stat-prefiltered checks -> classifyFreshness per claim. Returns new objects; the query path performs no graph writes. * feat: surface truth + Needs re-verification section in graph context Every claim line now shows its truth. Stale claims are quarantined into a new '## Needs re-verification' section (a distrust signal, omitted when nothing drifted); unknown-freshness claims stay in Best Claims with an 'unverifiable' caveat. * test: wire foreground freshness check into npm test * refactor: clarify foreground freshness code - anchor-fingerprints: collision-free JSON key (was a space-joined string), named StoredByAnchor type, and self-describing helpers (currentSpanHash, fileUntouched). - render: use the named FreshnessVerdict type; replace the emoji caveats with plain ASCII [STALE]/[UNVERIFIABLE] markers consistent with the rest of the packet. - context-builder: unnest the attachFreshness call into a named step. No behavior change; all checks pass. * fix: carry structurally-broken anchors through the unknown verdict When a claim mixes structurally-broken anchors with undeterminable ones, classifyFreshness returned state:'unknown' with broken:[], silently dropping the broken anchors — inconsistent with the content branch and losing info Phase 3's healer needs. Now the unknown verdict surfaces them too. Also harden the foreground content/structural assertions to check state, not just reason.
divo12
added a commit
to divo12/greplica
that referenced
this pull request
Jul 5, 2026
buildAnchorInvalidation now takes ClaimDemotion{claim, reason, anchors}
instead of structural-only DriftedClaim. Content demotions emit a
content_drift event recording the anchor's still-resolving status;
structural demotions keep the anchor_drift reason + drift-status guard.
Widen InvalidationReason and event resolver_status accordingly. Autoloops#96
structural path unchanged (regression green).
* feat: enumerate changed files (git diff + status, uncommitted-aware)
* feat: freshness_checkpoints table + fingerprint deletion (repository)
* feat: generalize demotion writer to content drift (ClaimDemotion)
buildAnchorInvalidation now takes ClaimDemotion{claim, reason, anchors}
instead of structural-only DriftedClaim. Content demotions emit a
content_drift event recording the anchor's still-resolving status;
structural demotions keep the anchor_drift reason + drift-status guard.
Widen InvalidationReason and event resolver_status accordingly. Autoloops#96
structural path unchanged (regression green).
* feat: add healDriftedAnchors (change-scoped structural + content heal)
Re-checks only claims in changed files (reverse index; full sweep when no
checkpoint), demotes genuinely-stale ones via the generalized writer,
deletes their fingerprints in the same txn, re-embeds, and advances the
freshness checkpoint. Never demotes on unknown. No agent spawn.
* feat: run change-scoped drift heal on the hook worker
Add session.autoHealDrift (default on) and a runDriftHealPass step in
runHookWorker: for each distinct active repo (deduped by root, gated by
config, lease-renewed), heal from the stored checkpoint. Best-effort and
deterministic — no agent spawn. Wire check-freshness-background into npm test.
* fix: harden background heal edge cases
- applyAnchorInvalidation always deletes demoted claims' fingerprints
(derived from events), so the CLI invalidate path cleans up too.
- changedFilesSince returns undefined on git-probe failure (vs [] clean);
heal full-sweeps on a bad checkpoint sha instead of silently skipping.
- content demotions name only the anchors that actually drifted
(reuse hasContentDrift), keeping the audit event accurate.
Skipped: FK on freshness_checkpoints — no repo-delete path exists.
* feat: re-verify worklist from existing state (no queue table) claimsNeedingReverify derives the worklist — current claims drift-demoted to truth:unknown and not yet rewritten — from readGraphView + invalidation events. A claim drops out for free once superseded by a fresh one. * feat: hand drift-demoted claims to an agent for re-verification Worker drains the re-verify worklist per active repo (gated by autoHealDrift, capped at 3/cycle) and runs the platform agent with a prompt to re-verify each claim against current code. Extract shared runMemoryAgent (reused by the memory-update path); drop now-dead safePathSegment. * fix: isolate re-verify per repo, log failures instead of aborting A reverifyWorklist/requireRepo error on one repo (e.g. greplica not installed there) aborted the whole re-verify pass. Wrap each repo in try/catch and log the narrowed error, matching the heal pass's best-effort isolation.
- anchor_fingerprints gains repo_id; reverse index is (repo_id, file) and claimIdsForFiles is repo-scoped, so a shared path across repos no longer cross-matches (was correct only by downstream intersection). New table, no migration. Regression test added. - span-hash: resolveWithinRepo uses realpath containment, rejecting symlinks that resolve outside the repo (not just literal ../absolute). - service: extract classifyCandidates from healDriftedAnchors (was ~52 lines). - worker: runDriftHealPass logs per-repo failures (parity with reverify pass).
changed-files.ts was a generic git-exec wrapper with no kg-domain knowledge, near-identical to utils/git.ts. Merge it in — both now share one git() exec helper — and drop the misplaced kg-domain file.
Author
|
Hi @kushalpatil07 , |
Contributor
|
Bro this is too extensive. Please discuss with me before implementing. Very unlikely this PR gets merged.
|
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Keeps
code_verifiedmemory claims honest as the code changes. Today a claim is checked against the code exactly once — at write time — and then drifts silently: a refactor renames a function, but the claim keeps itscode_verifiedbadge while pointing at code that no longer says what it claims. Worse than no fact — it hands the next agent a confident lie.This PR builds a freshness engine that closes the loop end to end:
A per-anchor content fingerprint is the authority. The foreground (every
graph contextquery) labels claims fresh/stale/unknown and quarantines stale ones — read-only. The background (hook worker) heals the durable graph: it demotes genuinely-drifted claims and hands them to an agent to rewrite. The compiler (tree-sitter) + a content hash are the judges — deterministic, no LLM in the detection path, zero false positives.Why
Two ways a
code_verifiedclaim rots after it's written:graph audit anchors), never healedfile#symbol, but its body changed → the fact is now wrongA per-anchor content hash of the resolved span unifies both: fresh iff the anchor resolves and the span hash matches the stored one; stale iff it won't resolve (structural) or the hash changed (content). Because the hash is taken over the working-tree file, it also catches uncommitted edits.
How it works
flowchart TD subgraph Write["① Write time — applyProposal"] A["agent saves a code_verified claim"] -->|hash resolved span| FP[("anchor_fingerprints<br/>baseline hash per anchor")] end subgraph FG["② Foreground — every graph context query (READ-ONLY)"] Q["graph context query"] --> RC["resolve anchors<br/>+ stat-prefiltered re-hash"] RC --> CF{"classifyFreshness"} CF -->|fresh / unknown| BEST["## Best Claims<br/>(unknown gets an 'unverifiable' caveat)"] CF -->|stale| NRV["## Needs re-verification<br/>(distrust signal to the agent)"] end subgraph BG["③ Background — hook worker on session end"] H["healDriftedAnchors<br/>changed files since checkpoint (git diff ∪ status)"] --> RIDX["reverse index: file → claims"] RIDX --> CF2{"classifyFreshness"} CF2 -->|fresh / unknown| SKIP["skip (early cutoff)"] CF2 -->|stale| DEM["demote: supersede → truth: unknown<br/>+ invalidation_events (one txn)"] DEM --> WL["re-verify worklist"] WL --> AG["④ agent re-reads current code,<br/>writes a corrected code_verified claim"] end FP -. baseline .-> RC FP -. baseline .-> CF2 AG -. supersedes the unknown claim .-> FPThe single rule (
code-anchors/freshness.ts, shared by both planes so the label the agent sees can never disagree with the demotion the daemon writes):Cheap by construction: a stat prefilter (mtime+size) skips the re-hash when a file is untouched; a reverse
file → claimsindex means the background only re-checks claims in files that actually changed (never a whole-graph scan); early cutoff means no write when nothing drifted; a per-repo checkpoint (last-checked SHA) scopes each background pass.Architecture & folder structure
New code is small and slots into the existing layer-organized
knowledge-graphlib (pure domain → util/IO → repository → service → surface). Nothing reaches "upward" intoapps/.Legend: ● pure domain · ○ util/IO · ▲ data-access · ■ service · ◐ read surface · ◆ background.
Data model (3 new tables, additive,
IF NOT EXISTS)erDiagram anchor_fingerprints { TEXT repo_id "scopes the reverse lookup" TEXT claim_id TEXT file TEXT symbol "'' sentinel for file-only anchors" TEXT content_hash "sha256 of the resolved span" INT file_mtime_ms "stat prefilter" INT file_size "stat prefilter" TEXT resolver_status TEXT checked_at } freshness_checkpoints { TEXT repo_id PK TEXT last_checked_sha "background scopes diff from here" TEXT checked_at } invalidation_events { TEXT id PK TEXT repo_id TEXT original_claim_id TEXT superseding_claim_id TEXT reason "anchor_drift | content_drift" TEXT broken_anchor TEXT resolver_status TEXT git_commit_sha }anchor_fingerprintsPK(claim_id, file, symbol); the''symbol sentinel avoids SQLite's "NULLs are distinct in a composite PK" gotcha soINSERT OR REPLACEstays idempotent. Index(repo_id, file)powers the reversefile → claimslookup, scoped per repo (the DB is shared across repos).truth: unknowncopy (broken anchors kept as evidence,about/evidenced_byedges re-pointed), so full history is preserved.invalidation_eventsis the queryable drift audit trail.User-facing surface
graph contextpacket now shows each claim'struth, routes stale claims into a## Needs re-verificationsection, and tagsunknown-freshness claims with an "unverifiable" caveat. Zero graph writes on the read path.session.autoHealDrift(default on).Key design decisions
classifyFreshness) for both planes — the foreground signal and the background demotion can never disagree.unknownnever demotes. An unreadable span is flagged unverifiable, never auto-demoted — no false stales.mtimeis only a prefilter, the hash is the authority.truth: unknown+ aninvalidation_eventsrow); a claim drops out for free once an agent supersedes it.Test plan
npx tsc --noEmitclean; fullnpm testgreen.check-anchor-drift.js— structural demote/supersede/event/history/edges + Option A + idempotency.check-span-hash.js— span hashing, CRLF normalization, repo-containment (missing / absolute /../ symlink → safe).check-freshness.js— theclassifyFreshnessrule (fresh / structural / content / unknown) + fingerprint store + repo-scoped reverse index.check-freshness-foreground.js— per-claim freshness in the builder +## Needs re-verificationrendering (read-only proof).check-freshness-background.js— change-scoped heal (reverse-index scoping, early cutoff, uncommitted-aware, unknown-never-demotes, checkpoint), the re-verify worklist, and the agent-handoff prompt.greplica graph contextthrough all four verdicts (content drift incl. uncommitted, structural(missing symbol),unknownviachmod 000, back to fresh on restore) and confirmed the durable graph was untouched by the read path.Non-goals / limitations
source_verified) staleness is a future detector behind the sameclassifyFreshnessseam.