feat(#231): wire the wsc.facts value-range SOURCE (constants, masks, booleans) - #322
Conversation
…booleans)
v1.3.0 shipped the `wsc.facts` emitter and said so plainly in the release
notes: "the fact *source* that would populate it at volume is not yet wired".
`module.facts` was only ever filled by a test injector, so the section shipped
empty in practice. This wires the source.
Three fact sources, each justified by the value's TERM SHAPE alone — never by
the program point, local/memory state or a path condition. That is what makes
value-keying sound: a structural range holds at every occurrence of the term,
so a fact can never be stale for the value it names.
1. Constants. `i32.const c` / `i64.const c` is exactly `c` -> [c, c].
Derived from the FINAL body, so a value the folder rewrote
to `i32.const 5` reports [5,5] at the operator the encoder
actually emits.
2. Masks. For a non-negative constant K, the set bits of `x & K` are a
subset of K's, hence 0 <= (x & K) <= K for ANY x, with no
assumption about x at all. K >= 0 also clears the result's
sign bit, so the signed and unsigned readings coincide.
This is the shape that makes a downstream bounds check
elidable.
3. Booleans. The integer relops and `eqz` return 0 or 1 by definition
-> [0, 1].
Operator keying, which is where a fact source can silently miscompile the
consumer: `Function::instructions` is a NESTED representation. The encoder
flattens Block/Loop/If bodies and an `Instruction::End` in the list encodes to
NO operator, so an instructions index is the emitted operator ordinal only
while every preceding entry encodes 1:1. The walk therefore models a whitelist
of operators that each encode to exactly one operator and STOPS at the first
instruction outside it — all structured control flow included. A test encodes a
body per modeled operator and asserts the DECODED operator count equals
instructions.len() + 1, so the claim is locked against the encoder rather than
asserted in a comment. Every test additionally re-decodes the emitted binary
and asserts the operator at `value_id` is the one the fact claims.
Renumbering safety is structural: collection runs on the final module,
immediately before the encode that consumes it, so a fact never outlives a
pass. A fact-bearing value deleted later is simply never derived; survivors are
re-keyed to the post-pass indices.
Deliberately NOT done, so it is not implied:
* A range whose `lo` is negative is dropped rather than emitted — the
consumer's reading of a negative bound for a value it may treat as
unsigned is not pinned down by the wire format visible from this repo, and
`FactSet::unsigned_max` already applies the same conservatism internally.
* The #240 premise map is NOT harvested: `assume_max`/`assume_range` have no
non-test callers, so the optimizer derives no bound of its own to record.
Harvesting an always-empty map would be theatre.
* No arithmetic propagation ((x&0xff)+(y&0xff) etc.) — true over the
integers, but wasm arithmetic wraps and a sound version needs a per-
operator overflow argument.
* Nested-body coverage. Keying a fact inside a block needs the FLATTENED
ordinal, which would mean changing the shipped emitter's bound check and
agreeing the convention with the consumer — not verifiable from this repo.
* The component pipeline and the WAT output path emit no facts; --facts on a
component now says so instead of looking like it worked.
Measured on this tree, not quoted: on the calculator component's unbundled
core module (2.3 MB, 352 functions) the source collects 322 facts — 314 point
facts on literal constants, 6 mask ranges, 2 booleans. The consumer-useful
yield is the 8 non-literal ones; a consumer already knows what `i32.const 5`
is. Facts-off output is byte-identical to origin/main on four inputs including
that module (153243 bytes, `cmp` clean).
Tests: 9 new in `facts::wsc_facts_source_tests`; loom-core --lib goes
481 -> 490 passed, 0 failed, 2 ignored. The 10 existing wsc_facts_* tests are
untouched and green.
Refs #231, #303
Verifies: TEST-WSC-FACTS-SOURCE
ce07edf to
bd91e86
Compare
|
Reviewed. The engineering is solid — value-keyed carriers reused rather than reinvented, drop rule untouched, byte-identity checked with Two things to change or decide before merge. 1. Don't emit point-ranges for literal constantsYour own measurement makes the case: 322 facts on the calculator core, of which 314 are point/literal and 8 are useful. A consumer reading That would be harmless if the section were free, but it isn't: this pipeline's neighbouring issue (#303) is about shrinking artefacts for parts measured in kilobytes, and we would be adding a custom section that is ~97% redundant. A fact channel whose payload is dominated by restatements of literals is also harder to reason about — the interesting facts are buried. Proposal: suppress the point-range when the value's producing operator is itself a literal constant in the emitted stream. Keep it when the range is a derived point (a value proven constant that is not spelled as a constant). Expected effect on your own numbers: 322 → 8, all of them load-bearing. If you disagree — e.g. the consumer's ingestion is simpler when every value it might reference is present — say so and we keep them, but then the section-size cost should be measured and stated rather than incidental. 2. Prefix-only coverage is the real limit, and it should be visibleYou identified this correctly as the binding constraint: the walk stops at the first structured control-flow instruction, so only the straight-line prefix of a function is covered. On real code that is a small fraction, and the 8 useful facts on a 352-function module are consistent with that. I am fine shipping prefix-only — soundness first, and extending to nested bodies needs the flattened ordinal plus consumer agreement, which is a bigger change. But the limitation must not be discoverable only by reading the source. Please state it in the module docs of Accepted as-is
Separately: your source-#3 finding is its own bug
I verified this independently and it holds — every call site is inside a test module. That means #240's "proof-carrying IR premises" half is inert: the hook exists, is tested, and nothing in the optimizer produces a premise through it. That is the fourth instance of this exact shape in the codebase (a trap gate with no callers, a pin held only by a comment, a proof that never ran). It is not yours to fix in this PR — filing it separately — but it is the reason source #3 was empty, and it is worth knowing that the emptiness was a defect rather than a design choice. |
Follow-through on a review point I raised on #322 and then merged over. Facts are opt-in, so nothing was broken -- but once a consumer ingests a 322-fact section, dropping to 8 becomes a compatibility conversation. Easier to not emit them than to stop emitting them later. A point range on an operator that IS a literal constant restates what the consumer reads directly off the instruction the fact is keyed to. Measured on a real component core module: 322 facts, of which 314 were exactly this. This section ships into images measured in kilobytes, so a payload that is ~97% restatement of literals is pure cost, and it buries the facts that carry information. Derived point ranges are still emitted: `x & 0` is provably [0,0] on an `i32.and`, and the consumer cannot read that off the operator. That is the interesting case and it survives. Consequence worth stating: "source 1 (constants)" now only fires when a value is provably constant but NOT spelled as a constant. A value the folder rewrote to `i32.const 5` is suppressed -- how it came to be a literal does not matter, only that the emitted operator already states it. Test rework, and one trap avoided: `walk_stops_at_structured_control_flow` had a literal as its only prefix fact, so blanking its expectation to "no facts" would have left a test that passes even if the walk were broken -- weakening a test to fit the code. Its prefix now uses a mask, so it still proves prefix facts ARE produced and post-control-flow ones are NOT. The drop-safety test keeps its load-bearing assertion (the mask fact re-keys 4 -> 2 after DCE removes two instructions); only the suppressed literal entries came out of its expectations. New: `derived_point_range_on_a_non_literal_operator_is_kept` -- the control for the suppression, with ground-truth checking that the fact names the i32.and in the emitted binary. Verified: loom-core --features verification --lib = 504 passed, 0 failed, 2 ignored (no change in count: the suppression removes no test). All 10 facts:: tests green. fmt clean. Refs #231, #303
v1.3.0 shipped the
wsc.factsemitter and said plainly in the release notesthat "the fact source that would populate it at volume is not yet wired".
module.factswas only ever filled by a test injector, so the section shippedempty in practice. This wires the source.
Fact sources wired, and why each is sound
Every derivation is a property of the value's term shape alone — never of
the program point, local/memory state, or a path condition. That is precisely
what makes value-keying sound: a structural range holds at every occurrence of
the term, so a fact can never be stale for the value it names.
i32.const c/i64.const c→[c, c]c. Derived from the FINAL body, so a value the folder rewrote toi32.const 5reports[5,5]at the operator the encoder actually emits.x & Kfor a non-negative constantK→[0, K]x & Kare a subset ofK's, so0 <= (x & K) <= Kfor anyx, with no assumption aboutxat all.K >= 0also clears the result's sign bit, so the signed and unsigned readings coincide. This is the shape that makes a downstream bounds check elidable.eqz→[0, 1]i320/1by definition.Facts are recorded on the loom
Valuein anOptimizationEnvvia the existingassume_range(the value-keyed carrier — never an instruction index), thenresolved to the producing operator in the final sequence. The emitter, its
drop rule and the
wsc.*trust boundary are untouched.Operator keying — where a fact source can silently miscompile the consumer
Function::instructionsis a nested representation. The encoder flattensBlock/Loop/Ifbodies (Block→block+ body +end) and anInstruction::Endin the list encodes to no operator at all. So aninstructionsindex equals the emitted operator ordinal only while everypreceding entry encodes 1:1.
The walk therefore models a whitelist of operators that each encode to exactly
one operator, and stops at the first instruction outside it — all
structured control flow included. Facts are produced only for the straight-line
prefix, where
index == ordinalholds by construction.That claim is tested, not asserted in a comment:
every_modeled_instruction_encodes_one_to_oneencodes a body per modeledoperator and asserts the decoded operator count is
instructions.len() + 1(the appended function
end). If a future encoder change makes a modeledoperator emit zero or two operators, this fails.
value_idis the one the fact claims.Renumbering safety is structural rather than a repair: collection runs on the
final module immediately before the encode that consumes it (both the serial
and the islands encode sites), so a fact never outlives a pass.
Tests
Nine new tests in
facts::wsc_facts_source_tests:mask_yields_zero_to_mask_range_factx & 0xFF→[0,255]reacheswsc.facts, keyed to thei32.and, with the schema-v1 bytes presentconstant_folded_value_yields_point_range_facti32.const 5carries[5,5]at the FOLDED index (0), not the pre-fold index (2)unjustifiable_values_yield_no_factwalk_stops_at_structured_control_flowblockyields nothing; only the straight-line prefix produces factsvalue_deleted_by_a_later_pass_is_dropped_not_miskeyedevery_modeled_instruction_encodes_one_to_onefacts_use_the_full_function_index_and_bool_rangefact_collection_is_deterministicVec<ModuleFact>(REQ-14)no_derivable_fact_means_no_sectioncargo test --release -p loom-core --features verification --lib→490 passed, 0 failed, 2 ignored (baseline 481; +9, none lost). The ten
existing
wsc_facts_*tests are untouched and green.Measured on this tree
On the calculator component's unbundled core module (2.3 MB, 352 functions):
322 facts — 314 point facts on literal constants, 6 mask ranges, 2
booleans. Stated honestly: the consumer-useful yield is the 8 non-literal ones;
a consumer already knows what
i32.const 5is by decoding it. Suppressing pointfacts on operators the consumer reads directly (keeping them only where folding
produced them) is a sensible follow-up, but the two cases are not
distinguishable post-hoc today.
Spot-checked against ground truth (
wasm-tools print), function 234:Facts-off byte identity: the branch binary's default output is
cmp-cleanagainst a binary built from
origin/mainon four inputs, including that2.3 MB module (153243 bytes out).
Deliberately not done — stated so it is not implied
negative bound for a value it may treat as unsigned is not pinned down by the
wire format visible from this repo;
FactSet::unsigned_maxalready appliesexactly this conservatism internally. Facts we cannot state unambiguously are
not stated.
assume_max/assume_rangehaveno non-test callers — the optimizer derives no bound of its own today, so
there is nothing to record. Harvesting an always-empty map would be theatre.
This was one of the three candidate sources in the brief; it is dropped with
evidence rather than faked.
(x&0xff) + (y&0xff)etc.). True over theintegers; wasm arithmetic wraps, and a sound version needs a per-operator
overflow argument.
flattened ordinal, which would mean changing the shipped emitter's bound
check and agreeing the convention with the consumer — not verifiable from this
repo. This is the single largest coverage limit and the obvious next step.
load8_u→[0,255], etc.). Airtight, butit widens the modeled operator set; left out to keep this change auditable.
--factson acomponent now prints a warning instead of looking like it worked.
Rivet
TEST-WSC-FACTS-SOURCEadded tosafety/requirements/verification.yaml(verifies REQ-1, REQ-4, REQ-5, REQ-12, REQ-14). Its
steps[].runnames theeight real test functions, so
rivet check verification-evidenceresolves them— it flags the same 5 pre-existing artifacts as on main and not this one.
rivet validate→ PASS (42 warnings), identical to the baseline.Refs #231, #303