Skip to content

The #293 update guard is single-use, and the default --repetitions 3 consumes it: a broken run publishes update_unattributed 0 #304

Description

@fcostaoliveira

The #293 guard added in #298 catches a misdirected update half exactly once per server lifetime, and the shipped default --repetitions 3 spends that one shot inside a single invocation. The run then publishes a clean-looking result with update_unattributed: 0.

Mechanism

Three facts on master e99d2b5, each individually correct:

  1. finalize_update_stats is infallible — the mixed phase completes all its writes before any verdict is reached. (This was deliberate, and right: it is what fixed the --allow-partial-corpus blocker.)
  2. gate_update_attribution is called at experiment.rs:1585, inside the repetition loop that opens at :1539.
  3. --repetitions defaults to 3 (cli.rs:182), and publication is best-of-N (:1655, " (best of reps)").

The writes that rep 1 detects as unattributed are the writes that create the rows whose absence it detected. Rep 2 therefore sees every write as a clean overwrite, reports update_unattributed: 0, and is published as the best rep.

Reproduction

400-row corpus, ids 300–399 renamed to 1000–1099 — still 400 indexed docs, so the reuse check reports verified and no waiver is involved.

### RUN 1 (--repetitions 1, corpus reset to 400):
exit=1  results=0  DBSIZE=430

### RUN 2 (identical command, nothing reset):
exit=0  results=2  DBSIZE=430
published: {'update_count': 120, 'update_unattributed': 0, 'update_rps': 1158.2, 'mean_recall': 0.74}

And within one invocation at the shipped default:

Experiment stage: Reuse check — server holds 400 of 400 expected rows
	Search failed (rep 1/3): mixed workload: 30 of 120 dispatched updates were accepted by the server ...
	→ QPS: 411.0, Recall: 0.7400, ... (best of reps)
--- result files: 2

{'update_count': 120, 'update_rps': 1233.0, 'update_attribution': 'corpus_row',
 'update_unattributed': 0, ...}
corpus_reuse = {'status': 'verified', ..., 'waived_by_allow_partial_corpus': False}

Why this matters

It defeats the guard's own headline scenario. A misdirected update half — the #293 source-bug class — writes to wrong:<id> on rep 1, is detected, and those keys then exist. Rep 2 reads every write as a clean overwrite and publishes. The published result file carries no trace: update_unattributed: 0, update_attribution: corpus_row, corpus_reuse.status: verified, no waiver.

Partial mitigation: record_rejected_experiment still fires, so the run summary contains both a rejection and a green point for the same (engine, dataset) pair — 1 entry in rejected_experiments, 1 in search_results. A reader of the per-search JSON alone cannot distinguish this run from a clean one.

Not introduced by the measurement/policy split

At 454d4d1, before that split, finalize_update_stats returned Err per-rep with the same effect. The split did not create this and did not fix it. The --allow-partial-corpus blocker it did fix is genuinely fixed — verified separately:

⚠ WARNING: mixed workload: 30 of 120 dispatched updates ... (continuing because
  --allow-partial-corpus is set; `update_unattributed` is recorded in the result file)
exit=0
published: {'update_count': 90, 'update_unattributed': 30, 'update_failures': 0, 'mean_recall': 0.74}

Why the review missed it

The gate was mutation-tested thoroughly and in isolation — never-abort, always-abort, inverted waiver, and a dropped denominator term were all caught, and four integration tests drive the failing branch end to end. Every one of those runs uses a single repetition. The dimension nobody varied is the repetition count, which is 3 by default. The guard is correct; its interaction with best-of-N publication is not.

Suggested direction

Hoist the decision above the rep loop: reject the point if any repetition reported unattributed writes, rather than rejecting a repetition. Alternatively carry max(unattributed) across reps into the published record, so a run that ever saw an unattributed write cannot publish a zero.

The second is weaker but strictly additive, and it preserves the property that made the split correct — measurement stays per-rep and infallible, and only the policy input becomes cross-rep.

Either way the fix needs a test at --repetitions 3 specifically. A test at 1 cannot see this.

Provenance

Found by an adversarial review of PR #298 that reported after the PR merged. Reproduced live against merged master e99d2b5 with a dedicated Redis on a private port; the two structural facts (repetitions default 3, gate inside the loop at :1585 vs loop at :1539) were independently confirmed by reading the merged tree.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions