Effort calibration — landed evidence
One cumulative record, updated as each node lands. The first node produced a qualified result: the promotion path now records where its completion time came from, and that recording is what exposed the fact that this backend supplies no timestamp to read.
Declared measures and what each has produced
| Measure | Evidence | Verdict |
|---|---|---|
| Migration is lossless | Letters map to 1/2/4/8 hours with uncalibrated plans marked, and a plan with neither field gains nothing — § hours unit | pass |
| Loops are separable | 2.0× agent moves only its factor; 2.0× plan moves only its estimate — § calibration loops | pass |
| Scope change excluded | A 200 h scope-changed outlier and a 50 h proxy-timed one are both withheld — § calibration loops | pass |
| Cold start terminates | Eight noisy observations converge to 3.009 h and factor 2.003 — § calibration loops | pass |
| Horizon drives splitting | 4.0 ÷ 1.4 = 2.857 h refused above a 2.5 h horizon with a 3.5 h split target; 1.5 ÷ 0.9 = 1.667 h dispatched — § competence horizon | pass |
| Cache is rebuildable | Delete and rebuild reproduced byte-equivalent figures; nothing is authored — § capabilities cache | pass |
| Lines are unrankable | An AST guard confines consumption to one descriptive helper and its single call site — § capabilities cache | pass |
| Unit stated everywhere | Plan read, sprint capacity, plans list and authoring guidance all name worker-hours; unit grep found 0 bare figures — § unit surfaced | pass |
| Prediction beats the letters | Exact tie at MAE 2.625391 h over 64 usable records — a recorded negative result — § prediction | negative |
| Suite green | Failed at 1,826/5, repaired, then 1,837 passed / 0 failed — § suite regression | pass |
The integrated branch is green, and promoting the check exposed a second defect
A read-only verification node ran uv run pytest -q once from a worktree of the
integrated primary branch: 1,731 passed, 0 failed, exit 0, 276 s, no failing
test names. That discharges the suite measure for every node landed to this point.
The node produced no commit, which is how it exposed the other half of the promotion-boundary
defect — and the real mechanism is broader than --commit none being accepted as a
revision. Promoting with no commit argument at all still populated
changed_lines, and what it stored was git's own error text:
changed_lines = {"detail": "fatal: /…/scratchpad/logs/integration-suite.log: '/…/scratchpad/logs/integration-suite…"}
So the field that is supposed to carry a diff statistic carries a truncated diagnostic string,
and no argument was needed to trigger it. The diff was attempted against this node's declared
write path, which is an absolute path outside the repository — legitimate for a log, meaningless
to git. Two distinct repairs follow: a failed diff must surface as an explicit
absence rather than as captured stderr, and a run that legitimately produced no commit must be
representable as such rather than inferred. A record carrying an error string where a number
belongs is worse than an empty one, because every later reader has to know it is not data.
Repaired below.
An unavailable measurement is an absence, not a diagnostic string
Commit 0a83bde — reckon/crew.py, reckon/ledger.py,
tests/test_crew.py, tests/test_ledger.py; 97 insertions, 24 deletions
across 4 files. Gate passed: 100 passed, 0 failed, 6.7 s, 3 new tests. Scope was
widened mid-flight, so this run is excluded from calibration.
| Case | Result |
|---|---|
| No commit argument | commits is an empty list and changed_lines is null; no diff is run and no git output is captured |
| Unresolvable commit value | Refused before promotion, naming the value, and the live pointer stays recoverable rather than being consumed by the failed attempt |
| Write path outside the repository | changed_lines is null; a diagnostic string cannot reach the field |
| Commit-backed diff | Still records added, removed and file counts |
Two details are worth keeping. The refusal leaves the pointer recoverable, so a mistyped revision costs a retry rather than a lost run. And promoting this very node showed the scoped diff measuring the declared fence rather than the widened one — 77 insertions across 3 files recorded against a commit of 97 across 4, because the fourth file was granted after dispatch. That is correct behaviour and it is exactly why a scope-changed run is withheld from calibration: its diff measures the fence it was given, not the work it did.
Promotion folds the run's terminal state, and names its source
Commit 69a3a21 — reckon/crew.py, reckon/ledger.py,
tests/test_ledger.py; 93 insertions, 5 deletions across 3 files. Gate
passed on its declared measure.
Completion no longer depends on someone having called observe at the right
moment. complete folds the run's terminal event directly, so token usage reaches
the ledger from the stream even when no observation ever happened, and
completed_at_source joins RECORD_FIELDS so every new record states
whether its finish time came from an event or from the wall-clock at promotion.
| Measure | Result |
|---|---|
| Terminal event honoured | Synthetic stream ending at 2027-01-02T03:04:05Z promoted with that exact completed_at, source terminal_event, and 29,253 input / 5 output tokens recovered with no prior observe |
| Fallback is explicit | Untimestamped stream fell back to promotion time and recorded completed_at_source=promotion_time |
| Field is universal | completed_at_source asserted present on every record build_record emits |
| Scoped suite | tests/test_crew.py tests/test_ledger.py — 97 passed, 0 failed, 6.4 s; 3 new tests; ruff clean |
Effort in worker-hours, with the letters mapping losslessly onto it
Commit 2325240 — reckon/_plan_html.py,
reckon/_schema.py, reckon/_store.py,
docs/_shared/plan.schema.json, tests/test_schema.py,
tests/test_capability.py; 198 insertions, 5 deletions across 6 files. Gate
passed: 1,098 passed, 0 failed, 24.3 s, 7 new test functions over 10 collected
cases, ruff clean.
plan-effort-hours validates as a positive number at quarter-hour granularity —
published in the regenerated schema as multipleOf 0.25 — and
effort_calibrated joins it as read-only provenance so a resolved figure always
states whether it was authored or inferred. Legacy effort is marked deprecated
rather than removed, which is what makes the migration cost nothing.
| Case | Result |
|---|---|
| Explicit hours | 3.25 h round-trips; 2.75 h through the state-set operation is accepted and marked calibrated |
| Hours and a letter together | 5.25 h with XL resolves 5.25, effort_calibrated=true, and reports the letter redundant |
| Letter only | S→1.0, M→2.0, L→4.0, XL→8.0, effort_calibrated=false, reported uncalibrated |
| Neither field | Gains none of effort, effort_hours, effort_calibrated or compatibility_warnings |
| Writes do not fabricate | A normal write preserves uncalibrated status instead of authoring derived hours into the document |
The last row is the property that keeps the unit meaningful: an inferred figure is resolved on read and never written back, so the mapping stays a compatibility read rather than becoming a silent re-estimate of every historical plan.
Paths report durations, not scores
Commit 07fb21b — reckon/roadmap.py,
tests/test_roadmap.py; 188 insertions, 21 deletions across 2 files. Gate
passed: 29 passed, 0 failed, 4 new tests.
The roadmap response now names its unit — unit=worker-hours — and carries
per-plan effort_hours and remaining_effort_hours with
length_hours on critical and open paths. Naming the unit in the payload is the point:
an unlabelled number invites the reader to assume the old doubling scale.
| Case | Result |
|---|---|
| Explicit hours win | 5.0 h overrides a legacy XL and yields 3.75 h remaining at 25% implementation |
| Letter-only | L maps to 4.0 h and the plan is reported uncalibrated |
| Path totals | Equal the sum of per-plan remaining_effort_hours within a tested 0.25 h tolerance |
| Uncalibrated visible | uncalibrated_count and uncalibrated_plans reported beside the totals |
| Completed plans | Zero-remaining plans excluded from the actionable uncalibrated count |
The last row keeps the count honest: a finished plan that was never estimated in hours is not work anyone can act on, so counting it would inflate the figure that is supposed to say how much re-estimating is left.
Two loops that do not contaminate each other
Commit b93cfcd — reckon/calibration.py,
tests/test_calibration.py; 364 insertions across 2 new files. Gate
passed: 9 passed, 0 failed, 9 new tests, ruff clean.
Separability is asserted in both directions rather than assumed, which is what the plan warned would quietly break if built naively:
| Case | Result |
|---|---|
| Slow agent, sound estimates | An agent at exactly 2.0× declared hours across three plans resolves to factor 2.0 while all three plan estimates stay identical |
| Underestimated plan, baseline agent | Four 4.0 h runs against a 2.0 h plan resolve the plan to 4.0 h while the factor stays exactly CalibrationFigure(value=1.0, confidence=1.0, samples=20) |
| Scope-changed run | A 200.0 h outlier is excluded from both loops, leaving the usable 2.0 figure and reporting one exclusion in its category |
| Proxy-timed run | A 50.0 h promotion_time outlier is excluded the same way |
| Cold start | From empty history, eight noisy observations converge to 3.009 h (confidence 0.764, 8 samples) and factor 2.003 (confidence 0.775, 8 samples) — both within 0.05 of their synthetic targets, without oscillating |
| Missing data | Missing counterparts and invalid durations are excluded explicitly rather than guessed |
The two outlier rows are what make the earlier repairs pay off. A 200-hour scope-changed run and a 50-hour proxy-timed run are exactly the shapes this sprint's own ledger produced, and both are now withheld by construction rather than by anyone remembering to filter them. Confidence travels with each figure, so an agent with eight samples cannot present its number with the authority of one with eighty.
A disposable capabilities cache, and what it says the ledger is worth
Commit 55e9148 — reckon/capabilities.py,
tests/test_capabilities.py; 501 insertions across 2 new files. Gate
passed: 10 passed, 0 failed, 10 new tests, ruff clean. Cache path
<RECKON_HOME>/cache/capabilities.json.
| Measure | Result |
|---|---|
| Rebuildable | Deleting and rebuilding reproduced byte-equivalent figures; direct derivation, rebuild return and the JSON cache were exactly equal |
| Nothing authored | Each ledger version and a deterministic source digest are recorded, so every value traces to a committed record |
| Size-conditioned success | success_by_estimated_hours is cumulative over configurable hour bins; competence_horizon_hours is the largest measured size meeting the threshold |
| Distribution, not just a mean | Speed carries sorted empirical values with mean, median, min, max, p10 and p90 |
| Lines unrankable | changed_lines is copied only into observation metadata, and an AST guard confines its consumption to one descriptive helper plus its single call site |
The AST guard is the right shape for that last measure. The plan required the bar against ranking to be enforced by test rather than convention, and a test that inspects the syntax tree fails when a future edit feeds the field into a sort or comparison — which a test asserting only today's outputs would not catch.
The honest figure: 59 runs, 0 usable configurations
Run against this project's live ledger, the cache read 59 committed runs and produced 0 configurations, having excluded 3 scope-changed and 56 untrustworthy-completion records. The exclusion rules are working exactly as specified; what they reveal is that the historical ledger is essentially empty as calibration input. The breakdown:
| Completion source | Runs | Usable | Why |
|---|---|---|---|
| absent | 38 | no | Written before the field existed |
provided | 12 | no | A hand-supplied stamp is not independently verifiable |
promotion_time | 9 | no | The known-inflated fallback |
stream_mtime / terminal_event | 0 | — | None yet at the time of the probe |
The 12 provided records are an own goal worth recording. They are
this sprint's own promotions, hand-corrected from the stream's last-write time while the automatic
path was still being built — accurate, but classified as unverifiable and therefore excluded. The
correction is to stop supplying the stamp and let the ranked-source code derive it: promoting the
capabilities node itself with no explicit stamp produced
completed_at_source=stream_mtime and 340 s, the first trustworthy record in
this ledger. Every later promotion follows that path.
This bears directly on the plan's final gate. A prediction comparison between hours and the legacy letters needs usable runs, and at the time of this probe there were none — so that gate cannot be discharged on historical data and must wait for records the automatic path produces. A negative result on sample size is a legitimate outcome; fitting the comparison to the data that exists would not be.
A measurement decides how small to split, and its absence decides nothing
Commit 98b3a03 — reckon/crew.py, tests/test_crew.py;
283 insertions, 8 deletions across 2 files. Gate passed: 76 passed, 0 failed,
4 new tests, ruff clean and formatted.
Dispatch reads the node's neutral worker-hours from the canonical plan — explicit or legacy-mapped — divides by the measured mean speed factor for the exact agent configuration recorded on run records, and compares the result against that configuration's measured horizon. All four paths were exercised through real dispatch calls:
| Case | Arithmetic | Verdict |
|---|---|---|
| Above the horizon | 4.0 ÷ 1.4 = 2.857 h vs 2.5 h | Refused before worktree creation, with a 3.5 worker-hour split target |
| Below the horizon | 1.5 ÷ 0.9 = 1.667 h vs 2.5 h | Dispatched and persisted with allowed=true |
| Empty capabilities cache | no figure | allowed=true, reason=no-measured-horizon |
| Null horizon for the configuration | no figure | allowed=true, reason=no-measured-horizon |
The last two rows are the ones that matter most right now, and they were specified deliberately rather than discovered: this project's capabilities cache currently yields zero configurations, so a horizon check that treated a missing measurement as a small one would refuse every node in the fleet. Absence is explicitly non-blocking, in the same spirit as an unknown budget signal never holding a wave.
Two ordering details worth keeping: the refusal raises before the budget check and before any worktree exists, so a refused node costs nothing and leaves nothing to unwind; and the allowance verdict is recorded on successful runs, so a later reader can see the check ran rather than inferring it from the absence of a refusal.
Every surface names its unit
Commit 590102e — reckon/mcp_views.py,
reckon/project_state.py, docs/ui/home.jsx,
skills/reckon-create/SKILL.md, tests/test_mcp_views.py; 195 insertions,
9 deletions across 5 files. Gate passed: 87 passed, 0 failed, 5 new tests, ruff
clean, and the required unit grep returned MATCH_COUNT=0.
| Surface | What it now reports |
|---|---|
| Typed plan read | estimated_hours, spent_hours, remaining_hours with unit=worker-hours |
| Sprint summary and discovery cards | total_hours with unit=worker-hours, summed from items hydrated out of live plan HTML |
| Plans list | Explicit estimates rendered as worker-hours |
| Authoring guidance | reckon-create instructs hours at quarter-hour granularity rather than a letter |
Spend is derived — estimate multiplied by implementation fraction, with remaining as the
difference — and the node hit the consequence of that head-on: adding derived capacity to sprint
state broke durable migration parity, 10 failures and 38 errors, because a computed figure had
entered a store that compares persisted bytes. The resolution was to exclude derived capacity and
hydrated effort from durable parity rather than to persist them, which is the same principle the
capabilities cache follows and the one derived-gate-state exists to enforce. Final lane
87 passed, 0 failed.
One honest limitation recorded by the worker: the fleet search reads explicit hours from each plan endpoint because discovery inventory still carries only the compatibility letter. The figure is correct; the path to it is less direct than it will be once inventory carries hours.
The closing gate — hours did not beat the letters, and could not have
No commit; analysis only, at repository revision 8023dc6. The full calibration suite
stayed green alongside it: 9 passed, 0 failed.
Each prediction was reconstructed from plan state as committed at the run's own
base_sha rather than from today's plan, so no prediction benefits from hindsight.
Hours-based remaining effort used historical effort_hours × (1 − historical
impl); the comparator used the historical S/M/L/XL mapping 1/2/4/8 against the same
remaining fraction, matching the pre-migration implementation.
| Quantity | Value |
|---|---|
| Ledger records examined | 75 |
| Calibration-usable | 64 |
| Excluded — scope-changed | 4 |
| Excluded — untrustworthy completion | 1 |
| Excluded — no agent configuration | 5 |
| Excluded — zero duration | 1 |
| Hours-based MAE | 2.625391 worker-hours |
| Legacy-letter MAE | 2.625391 worker-hours |
| Lower | Neither — exact tie |
The tie is not a coincidence and not a measurement of the hypothesis. All 64
usable snapshots resolved their hours through the legacy letter fallback, because not one plan in
the sample carried an independently authored plan-effort-hours — the unit landed during
this same sprint, so every historical plan still resolves 1/2/4/8. Two numerically identical
predictors cannot separate, and the identity is what makes the result informative in a different
direction: it is a clean demonstration that the migration is lossless, since the new
arithmetic reproduces the old to seven digits over 64 records.
So the plan's closing question is unanswered by construction, and that is the finding rather than a shortfall to tune away. The plan's own constraint anticipated exactly this and forbids the alternative: the weights were not adjusted, the comparator was not softened, and no subset was selected to manufacture a difference. Testing whether authored hours predict better requires plans that carry authored hours, which is a trigger for a later re-measurement, not work that can be finished now.
Worth noting what the exclusions cost. Eleven of 75 records were unusable, and 5 of those simply lacked an agent configuration — a gap in older records rather than a defect in the current path. The exclusions are reported rather than quietly dropped, which is the whole point of counting them.
Gate FAILED — five integration regressions no single node's gate could see
The full suite on integrated revision 279b6f1: 1,826 passed, 5 failed,
exit 1, 58.7 s. Every one of the fifteen nodes landed so far passed its own scoped gate,
and the suite still broke — which is the entire argument for keeping a separate integration gate
rather than trusting the sum of scoped ones. Downstream work is held per
gates.on_fail, not worked around.
| Failing test | Cause |
|---|---|
test_budget.py::test_promotion_preserves_backend_when_the_agent_block_is_absent |
The fixture promotes the placeholder revision deadbee, which the new commit
boundary correctly refuses. The test asserts the old contract. |
test_fleet_migration.py::test_fleet_run_snapshots_every_mount_and_applies_only_selected_project |
Observed a rolled-back state where it expected verified. |
test_fleet_migration.py::test_install_failure_restores_snapshot_exactly |
Verification rejected one legacy capability before the deliberately injected installation failure, so the test never reached the failure it was written to exercise. |
test_fleet_migration.py::test_cli_and_commit_record_keep_ledger_machine_readable |
A CLI result lacked the after key. |
test_provenance.py::test_non_plan_canonical_dump_omits_neutral_plan_defaults |
A neutral milestone value of an em-dash reached plan-only validation on a non-plan canonical dump — so the tightened milestone constraint is firing where it should not apply. |
The five are not one fault. The first is a stale fixture asserting a contract this sprint deliberately changed, and its repair is to update the fixture, not to loosen the boundary. The last is a genuine defect in the tightening itself: a constraint scoped to plans is being applied to a non-plan resource, which is a scoping bug rather than a validation that is too strict. The three fleet-migration failures share a neighbourhood — snapshot, verification and CLI reporting around the same migration path — and the likeliest common cause is a schema addition reaching a verification step that enumerates known fields.
The verification node modified no repository file and did not rerun the suite, which is what a read-only gate should do: report the failure precisely enough to scope its repair, and change nothing.
Repaired, and re-gated green
Three corrective nodes, one per root cause. The full suite on integrated
99583a4: 1,837 passed, 0 failed, exit 0, 54.3 s, no failing test
names.
| Repair | Commit | What it changed |
|---|---|---|
| Promotion fixture | b8a9934 |
The fixture resolves its repository HEAD to a full SHA and promotes that. The strict boundary is untouched — the test was updated to assert the current contract, not the contract relaxed to suit the test. |
| Non-plan validation scope | 21288f8 |
The legacy milestone placeholder joins the neutral values stripped from non-plan canonical writes. A plan still refuses an em-dash and a non-plan still refuses a meaningful milestone, both proven by existing tests staying green, plus one new case pinning the boundary. |
| Fleet verification | 5ef8876 |
Counts persisted tier fields directly from repository_inventory instead of
reading audit warnings. |
The fleet root cause is the most instructive of the three. The field reaching
verification was effort_calibrated=false, derived from a fixture's legacy
plan-effort S. Its uncalibrated-effort compatibility warning was emitted under the
broad legacy-capability-tier code, so verification mistook an effort
diagnostic for a persisted tier and rolled the migration back. The defect was not the new
field; it was that a verification step inferred structure by pattern-matching a diagnostic string.
Counting the fields directly removes the coupling rather than teaching the matcher about one more
field it should ignore.
Two process notes from this repair, recorded because both are coordinator errors rather than
worker ones. A manifest reported commit 21288f8fdd927ed6e169968cb8f5ef70bc333411,
which does not exist — the real commit is 21288f82216afce3bb5ebaa1d47fbf16c0446337,
agreeing only on the abbreviated prefix. git merge refused it, which is the only reason
a nonexistent SHA did not reach this record and the plan's commit list. And the fleet node's gate
command named a test file a concurrent node owned, so its lane could not go green regardless of its
own work; its three target tests passed, and the red was a gate-specification error.
A repair pass, and what it says the ledger can become
Commit 73f43f4 — reckon/ledger.py, reckon/cli.py,
tests/test_ledger.py; 390 insertions, 6 deletions across 3 files. Gate
passed: 45 passed, 0 failed, 6 new tests, ruff clean and formatted.
reckon crew repair-completion re-derives completion for existing records from
their surviving stream files, ranking the newest aware event timestamp above the newest mtime
across stream.jsonl and every resume-*.jsonl, and re-deriving
worker_seconds with it. It reports without writing by default and persists only with
--write; a second write pass performs no write and leaves the ledger version
unchanged, so the operation is idempotent rather than merely repeatable.
The read-only preview over this project's live ledger — 65 records at version 77 — is the figure that matters:
| Outcome | Records |
|---|---|
| Repairable | 58 |
| Already correct | 6 |
| Unusable — no surviving stream | 1 |
| Source that would supply the value | Records |
|---|---|
stream_mtime | 62 |
terminal_event | 2 |
| none surviving | 1 |
Two observations. Two records resolve from a real event timestamp, so the
terminal-event path is not dead after all — some runs in this ledger do carry one, which means the
ranking is doing real work rather than always falling through to the proxy. And the single
unusable record stays byte-identical and is reported calibration_usable=false rather
than being rewritten with a guess, which is the distinction between repairing a measurement and
inventing one.
Against the earlier finding that 59 records yielded zero usable configurations, this is the route out: a write pass would move roughly 58 records into the trustworthy sources that effort reporting accepts.
The write pass, and the measurements it unlocked
Commit 540ccde. The authorised pass repaired 58 records, left 9
already correct, reported 1 unusable, and advanced the ledger once from version 80 to 81. A second
identical invocation reported updated=0, written=false, version 81 to 81 —
idempotent, not merely repeatable. A structural comparison proved exactly 58 records changed and
that only completed_at, completed_at_source and
worker_seconds changed within them.
| Completion source | Before | After |
|---|---|---|
| absent | 38 | 1 |
promotion_time | 9 | 0 |
provided | 12 | 0 |
stream_mtime | 9 | 65 |
terminal_event | 0 | 2 |
The one streamless record, r-20260812T084627660648-suite-native, was reported
action=unusable and calibration_usable=false and left alone — its
canonical byte hash d49427cc…c7c25d85 was identical before and after, which is the
proof that a record without evidence was not repaired with a guess.
What the repaired ledger then says about the letters. Before the repair,
uniform-worker-dispatch reported a mean of 252.7 minutes per node with a 405.9-minute
spread. After it, the per-plan means collapse into a plausible band and the totals become
comparable against the declared letters read as hours:
| Plan | Declared | As hours | Measured worker-time | Runs |
|---|---|---|---|---|
inflight-visibility (complete) | M | 2.0 h | 0.65 h | 7 |
derived-gate-state | XL | 8.0 h | 2.3 h | 27 |
effort-calibration (partial) | L | 4.0 h | 1.2 h | 13 |
budget-aware-dispatch | M | 2.0 h | 0.60 h | 4 |
north-star-orientation (partial) | M | 2.0 h | 0.27 h | 3 |
uniform-worker-dispatch | L | 4.0 h | 7.8 h | 11 |
Read carefully, because two caveats matter more than the headline. uniform-worker-dispatch
still carries a single 413-minute run against a 1.1-minute minimum; without it the plan sits near
0.9 h, so that row is one outlier rather than a counter-example, and it is a candidate for the same
scrutiny the repair just applied. And measured worker-time is not elapsed work: it
counts only what a worker process spent, excluding every minute the orchestrator spent scoping,
reviewing, merging and recording — which in this session was a large fraction of the wall clock. So
the honest statement is that the legacy letters over-estimate worker hours by roughly
threefold on completed plans, not that the work was three times cheaper than believed.
Four runs are excluded as scope-changed, exactly as intended: three of this sprint's own widened nodes plus one earlier. They are counted where a reader can see them rather than averaged away.
Negative result — the backend's stream carries no timestamps, so the inflation survives
The node above was specified against a synthetic stream and passes on one. Promoting the two
real runs of this same wave through the new code showed the fix does not reach the defect it was
written for: both real runs recorded completed_at_source=promotion_time,
because this backend's --json stream contains no timestamp of any kind. Every event
in a full 296 KB stream carries only type, item or usage
keys; a scan for any key containing time, ts, stamp or at
returned none. The terminal turn.completed event supplies usage — which is why the
token half of the fix works on real runs — and no clock.
So the measurable residue is unchanged in kind and only smaller in size than the 415-minute case that motivated the repair. Both figures below are inflated by the interval between a worker finishing and the orchestrator getting to its landing beat:
| Run | Dispatched | True finish (last stream write) | Recorded | Recorded worker time | True | Inflation |
|---|---|---|---|---|---|---|
north-star-project-state | 17:22:02Z | 17:27:26Z | 17:29:46Z | 464 s | 324 s | +140 s (+43%) |
promotion-terminal-read | 17:22:13Z | 17:26:16Z | 17:31:25Z | 551 s | 243 s | +308 s (+127%) |
The bias is not random: it grows with how much landing work the orchestrator does between a worker finishing and that worker being promoted, which means it correlates with wave size and would systematically inflate exactly the runs dispatched in the widest waves. A speed factor fitted on these numbers would learn the orchestrator's promotion latency and attribute it to the worker.
The finish time is nonetheless recoverable without any backend cooperation: the last write to
stream.jsonl is the terminal event's wall-clock, and the run directory
survives promotion. Both figures in the table above were derived that way. The repair is
therefore a third source ranked between the two that exist — event timestamp where a backend
supplies one, else the stream's last-write time, else promotion wall-clock — each recorded
distinctly so no reader can mistake a proxy for a measurement. Landed below.
Three ranked completion sources, and proxies kept out of calibration
Commit e57b28b — reckon/crew.py, reckon/ledger.py,
tests/test_ledger.py; 168 insertions, 42 deletions across 3 files. Gate
passed: 105 passed, 0 failed, 6.3 s, 5 new test functions plus one updated
fallback regression.
The vocabulary is three ranked automatic values — terminal_event,
stream_mtime, promotion_time — so a record always states which of them
produced its figure.
| Case | Result |
|---|---|
| Event timestamp available | Ranks above filesystem time and is used |
| No event timestamp | Resolves the newest mtime across stream.jsonl and every resume-*.jsonl |
| A resumed run | Either the original or a resume stream can win by being newest, so a run that blocked and was answered is not read as finishing when it first stopped |
| No surviving stream | Records promotion_time explicitly |
| Calibration input | effort_report excludes promotion_time records and reports excluded_unusable_completion at both report and plan-row level |
The resumed-run case was found by measurement rather than by design: a resumed worker writes
resume-1.jsonl and never touches the original stream again, so a naive last-write
proxy read one of this sprint's own runs as finishing 11 minutes early — wrong in the opposite
direction from the promotion-time inflation, and therefore invisible to any check that only looked
for overstatement.
The exclusion is what makes the whole repair safe rather than merely better: a proxy duration
is still a proxy, so rather than being quietly averaged in, a promotion_time record is
withheld from calibration and counted where a reader can see how many there are.