Files
jianf 7652184832 fix(memory): honour the C4 order — a stale round recalls with UNCORRECTED vectors
The C4 slice instruction said, verbatim: "判 stale 的那一轮仍返回结果(用未校正向量)".
It was not implemented. `grep stale lib/recall.js` returned one comment and no code,
and `loadCentroid` carries no staleness verdict — so recall centred with whatever
mean it found, however old.

## Why not simply ask on the recall side

The obvious fix — call `centroidStatus` in `recallBlocks` — was built and measured:

    # tests 305  # pass 304  # fail 1
    not ok 97 - C3-k: the recall hot path must not run a COUNT query
      error: 'lib/recall.js must not call centroidStatus — it runs countVectored
             internally, and the recall path may only read the centroid'

C3-k is a rule this project set deliberately. A fix that breaks it is not a fix,
and the criterion caught the attempt — which is what it is for.

## What this does instead

The WRITE path already pays for the judgement on every commit (`#ensureCentroid`
calls `centroidStatus`). So it persists the verdict:

  · `BlockStore#markCentroidStale(fp)` — `json_set(stats, '$.stale', 1)` when the
    space is judged stale
  · `#ensureCentroid` marks on `status.stale === true`
  · `recallBlocks` honours it: `!centroid.stats?.stale` before centring
  · cleared implicitly — `rebuildCentroid` → `saveCentroid` upserts stats without
    the key

Recall never runs a COUNT; C3-k stays green.

## The bug this shipped with for one revision — and why it matters

The guard was first written `centroid.stats?.stale !== true`. The flag is stored as
the JSON number `1`, and in JS **`1 !== true` is true** — a strict comparison across
types — so the guard was a permanent no-op. Stale centroids kept being used.

**The entire suite was 305/305 green with that bug in place.** Nothing covered the
path. It was only visible on a fixture where centring is observable (the C4
hub/target space):

    fresh centroid    top1=target  0.9000 / 0.2129 / 0.1814
    marked stale      top1=hub     0.9928 / 0.9859 / 0.9803   <- raw cosine, uncorrected
    after rebuild     top1=target  0.9000 / 0.2129 / 0.1814

Hence C4j (below) rather than a comment claiming it works.

## Criterion C4j

Three assertions on that fixture: fresh → target; marked stale → hub AND the score
is the raw cosine (0.9928); rebuild → target again. Mutation — deleting the guard:

    not ok 11 - C4j: a centroid marked stale falls back to uncorrected vectors
    error: ... If this still says "target", the flag is being ignored — the exact bug
           that a strict `!== true` comparison produced while the whole suite stayed green.
           'target' !== 'hub'

## What this does NOT establish

That falling back improves ranking quality. An attempt to measure it could not
separate the cases: with a near-duplicate late corpus, scores saturate near 0.99
whether the mean is stale (0.997) or absent (0.994); only a freshly rebuilt mean
spread them (0.978..0.970). So this is a safety choice — a mean built from a
fraction of the space describes a space that no longer exists, and applying it is
unjustifiable on principle. The measurable win belongs to keeping the centroid
FRESH (the throttle in C3), not to skipping it.

Gates: npm test 306/306 (0 skipped), npm run check 12/12, frozen faces untouched.
2026-11-05 12:34:29 +08:00
..