sots-re/findings/objects/wire-schema-ratchet-debt.md
alex befaf3dbfc lane WS: pay the ratchet debt -- prep/TacReports/Lay/trdmgr/fwarn typed, 97.6% -> 99.9959%
TacReport in particular: TRnc is a count and TRships/TRsats/TRshipsL are ONE
interleaved loop body, not three trailing runs -- which is why two lanes could
not settle it from a flattened linear recovery with TRnc == 0 everywhere.

Also two corrections the conformance binding found: Game::CombatReport's auto
and cdst are bools and dur/cdt/cdi are floats (cdt was being read as
1070805848 instead of 1.598), and Game::CombatWeaponReport's damage quartet is
flat with float dami/damt -- the nested 'dams' frame the shape modelled does
not exist, and modelling it also inflated the coverage denominator by one per
weapon report.

FTPnts stays carried and labelled: the one item the recovery itself marks
unresolved, count 0 everywhere, element framing a property of the helper.
2026-09-08 20:15:38 -04:00

11 KiB
Raw Permalink Blame History

Paying the ratchet debt: the five bodies the new saves exposed

Lane WS, 2026-09-08. Host/static only — no VM. Continues findings/objects/wire-schema-closeout.md (lane W) and findings/objects/wire-schema-channel.md (lane G).

Headline: 97.6 % → 99.9959 % on the worst save, and the bar did not move

The corpus went from 11 saves to 20 in one evening as lanes manufactured workloads nobody had. The coverage ratchet in test_save.cpp broke on contact — named coverage fell to 97.6 % against a 99.99 bar — because four new saves carry content no shape named. That is the ratchet working (rule 27). The reader was sound on every one of them the whole time: round trip: tree identical, typed identical. It carried the bytes; it did not name them.

Named coverage per save, before → after. The honest metric is CoverageArchive's split of items a field names from items a generic Node merely carries (rule 7), never round-trip success.

save before after opaque items
human-turn5-traderoutes 99.0003 % 99.9951 % 409 → 2
human-turn8-traderoutes 98.6349 % 99.9955 % 613 → 2
human-turn11-spytechs 97.6382 % 99.9959 % 1166 → 2
human-turn15-spyprogram 96.9412 % 99.9964 % 1706 → 2
the other 16 saves 99.9940–99.9953 % unchanged 2 → 2
corpus 99.5021 % 99.9949 % 3926 → 40

All 20 saves are above the ratchet. The 2 items left on every save are the MT19937 blob, which is deliberately opaque. No path stayed opaque that any save exercises.

Conformance: 100 shapes bound, 966 items matched, 0 MISMATCH (was 87 / 856 / 0). Fourteen new bindings, every one a full match with zero wire-only and zero shape-only items. ctest 58/58 over all 20 saves; tools/clean_room_check.sh OK; shim cross-build clean (see §6).

1. <rest:prep> — 704 items on turn11, the largest. Closed.

Game::CombatPlayerReport's tail. The shape stopped at mxmsl and swept the rest into ar.rest. The recovery had the whole thing all along — the corpus simply had no combat report until the trade saves — and the data confirms the flattened loops item for item:

plr ai ally status mxeng mxcls mxmsl
ncls                                    <- count
  cls nshp nsat nshfld nshlst nsatl     <- x ncls, one loop body of six
nsec                                    <- count
  ssec nshp                             <- x nsec, one loop body of two
ndam
srep { count, elements }                <- Game::CombatShipReport

Game::CombatShipReport in turn: name did cls caps2 nshp nfld nlst dtak dams damp dami damt, where caps2 is an i64 (the Caps bitfield) and dami/damt are floats.

2. TacReports — 369 items, and why two lanes could not close it

Lanes B5 and J both recorded Game::TacReport as carried-not-typed, with the same reason: the recovery ends with a computed count TRnc and then three trailing scalar items — TRships, TRsats, TRshipsL — and there is no way to read a flattened linear pass and tell

  • three trailing runs (TRships × n, then TRsats × n, then TRshipsL × n), from
  • one loop body of three (TRships TRsats TRshipsL, × n).

Both produce the same three items in the same order in the recovery, because the recovery cannot see the loop. And TRnc was 0 in every save in the corpus, two levels down under a count that was itself 0, so the wire could not arbitrate either. Neither lane guessed, which was right.

The trade saves settle it by inspection. TRnc == 3, and the wire reads:

TRnc 3
TRships 0  TRsats 0  TRshipsL 0
TRships 0  TRsats 0  TRshipsL 0
TRships 0  TRsats 0  TRshipsL 0

Interleaved. One loop body of three, run TRnc times. It is a narr, and the whole 20-item class now binds at 20/20. Game::TacReportEvents (TRby / TRto) comes with it: two ints, four floats, one bool.

This is rule 6 working as intended in both directions — the path was flagged as a hypothesis rather than closed on a guess, and when a save finally exercised it the answer was one line of dump.

3. Lay — 79 items, entirely new. Closed.

Game::FleetLayout, gated by HLay, which is false on every fleet in the old corpus. Three NULL-named items: a Game::FieldTemplate frame, a count, then that many ship ids. The count word is on the wire (narr), so nothing here is inferred.

4. <rest:trdmgr> — 9 items — is rt, and it has no count word

Game::ServerTradeManagerImpl writes NumTradeSectors, the sector pairs, SctSize, and then Game::TradeRoute frames tagged rt. The recovery marks rt a loop body (member: false) and there is no count item before it in the table and none on the wire — the trdmgr frame holds 1 + 2×6 + 1 + 1 = 15 items on turn11, which accounts for every one. So it is an uncounted run, read with ar.repeat("rt", …) keyed on the tag, the same shape as DSec in a design.

Both saves that have any route at all carry exactly one, so "uncounted" is the framing the recovery supports; "exactly one" is not a claim the evidence can make.

Game::TradeRoute is eight ints: tro trfow trfr trfrs trtow trto trtos trtc.

5. fwarn — 3 items — and two more the brief did not list

  • fwarn = Game::ServerTradeSector::FreighterWarning, {pid, ntrns}. The count was 0 in every sector of every save until turn11, where a raided route produced one.
  • spy = Game::SpyCraft (14 items on turn15, the first save with nspy != 0): five ints, two floats, four ints, a float, then an ncp count and its cpl elements.
  • Dwg = Game::WeaponGroups (17 items on turn15, the first design with Dwgv set): wgng count, then (wgid, wgb) pairs where wgb is a Game::GunBankSelection frame. That frame carries a run of NULL-named ints — three in the only record we have — with no count word, so it too is read as a run rather than as a fixed three.

6. Two corrections found by binding shapes to the table (rule 11)

Neither was visible from the round trip, which is the point of the conformance test.

Game::CombatReport: five items were the wrong primitive. auto and cdst are BOOLs; dur, cdt and cdi are FLOATS. All five were int32 in the shape. Four of them are 0 or 1 on every save, where a bool and an int are the same bytes under a four-character tag, so nothing ever moved. cdt is not: it holds 0x3FCC…, and the shape was reading 1070805848 for what is 1.598. This is the same trap as odet in an earlier round, one level up.

Game::CombatWeaponReport: the damage quartet is flat, and the last two are floats. The old shape reached dams damp dami damt through an obj_flex sub-struct, i.e. it modelled a nested dams frame. The binary does not write one — the recovered class lists the four as its own scalars, grade verified — and no save in the corpus contains one either.

That second one also had a measurement consequence worth recording under rule 12. CoverageArchive::obj charges one typed item for the frame it is about to descend into; under obj_flex with framed == false there is no frame, and it charged anyway. So the coverage denominator was inflated by exactly one per weapon report. The four affected saves have 8, 13, 21 and 32 weapon reports, and their before/after item totals differ by exactly 8, 13, 21 and 32. The pre-fix coverage figures in the table above were therefore slightly optimistic as well as too low; the after figures are on the corrected count.

7. What stays carried, and the workload that would settle it

One item from this round, deliberately:

FTPnts (Game::FieldTemplate) — the only item in this work that the recovery itself marks unresolved. It names the element class from the decorated helper name (Game::FieldTemplate::Point, whose own serializer is fully recovered: FTPShID FTPDesID FTPPosX FTPPosY FTPSqd) but could not type the item, and the count is 0 in every Lay in the corpus. That is precisely the pair of conditions under which SysMem, mts and nalat were each typed wrong and stayed wrong invisibly: the element framing is a property of the writer's helper, not of the element class, and no record has ever shown one. So the elements are carried, and Game::FieldTemplate::Point is deliberately not bound in the conformance test.

Workload: a save whose fleet has a stored tactical formation — set a fleet's combat layout in the tactical setup screen, then save. One such save closes it, the same way turn15's single spy craft closed spy and its single Dwgv design closed Dwg.

Everything else still carried is a container no save has ever filled — nodePaths, Ojvs, usnc, the four SpyReport sub-lists, the deflay/rtgt pairs, and the polymorphic keys with no factory entry — plus the RNG blob. None of them costs a single item on any save in the corpus.

8. The honest floor

The ratchet stands at 99.99 % and every save clears it, so there is no new floor to declare. The tightest save is zuul-turn5-species5 at 99.9940 %, and its whole residual is the RNG blob's two items against 33,316 typed. A bar of 99.99 is now equivalent to "nothing but the RNG blob", which is the right thing for it to mean.

The number that will move next is not this one. It is the count of hypotheses in §7: each is worth exactly one manufactured save, and until then each is a labelled guess, not coverage.

9. Gates

Run as separate commands on fresh build directories (rules 13, 24):

  • tools/clean_room_check.sh — OK
  • host ctest — 58/58, SOTS_SAVES_DIR pointed at all 20 saves
  • shim cross-build (--preset shim) — clean, 129/129

Caveat on the third, stated rather than assumed (rule 13): CT111 was unreachable from this lane (Permission denied (publickey,password), and ReVa was down with CONNECTION_CLOSED throughout), so the cross-build was run locally against i686-w64-mingw32-g++ (GCC) 10-posix 20220113. That is not necessarily CT111's toolchain, so the integrator should still run the CT111 shim build. It did catch one thing worth having: Game::TacReport's TRmin field wanted a member named min, which windows.h defines as a macro — it is mine in the shape for that reason.

Note the win32-threads variant of that compiler cannot build src/mars/vfs/zip_archive.cpp at all (std::mutex does not exist); the posix variant is required.

Files

sots-engine wip/ws: src/mars/stream/shapes.h, tests/mars_stream/test_wire_schema.cpp, tests/mars_stream/test_stream.cpp, docs/mars-stream.md. No generated header changed — every fact used here was already in objects/streams.json and include/generated/sots_stream_schema.h; what was missing was never the schema, it was a save that put something in the containers.