Commit Graph

1469 Commits

Author SHA1 Message Date
Edward Langley f26cc2a22a fix(core,io): three findings from the code-review skill on this branch
Self-review by fault injection, not reading. Three findings, one a real bug
this branch would otherwise have shipped and two vacuous tests of my own.

1. A REAL BUG THIS BRANCH CREATED THE DURABILITY OF. Model::remove_item left
   measure_agg holding an entry for a measure it had just deleted -- exactly
   improvise-qx1z's defect one layer down, in the same branch that fixed the
   View half and did not look at the Model half. Harmless while improvise-ppru
   was unfixed, because nothing persisted the map; persisting it turned a
   stale entry into a permanent one, so the document grows an Aggregation line
   naming a measure it does not have and re-adding that measure brings back an
   aggregation nobody asked for. A fix that introduces a format for state that
   outlives what it names is how a latent bug becomes a permanent one, so the
   sweep ships with the writer.

2. A VACUOUS CONTROL. a_file_naming_no_view_keeps_the_seeded_default PASSED
   under the over-broad "always drop Default" it exists to catch, because
   delete_view refuses the LAST view -- a guard one level away from the
   condition under test. It answered "does delete_view refuse the last view"
   where it meant "does drop_unnamed_default decline to fire". Split in two:
   a discriminating case naming Default AND a sibling, where the wrong
   condition really deletes, and the legacy no-views case kept separate and
   marked as the weaker statement it is.

3. A VACUOUS NEW TEST. a_category_named_prune_is_not_read_as_the_prune_flag
   passed under the rejected presence-style grammar that swallows "prune: row"
   whole, because the fixture used Row and on_category_added RE-DERIVES Row for
   the first regular category on reload -- the axis matched by reinvention, not
   by being read. Now uses Page plus a page selection, neither of which any
   default supplies.

Also verified and unchanged: the generator reaches all three new grammar
branches, including the scalar data line, each proved by a separate injection;
awkward measure names (|A: B|, |sum|, true) round-trip through the Aggregation
section; and improvise-nvq4 still does not reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:17:06 -07:00
Edward Langley 7509417d0b test(import): three review findings — a rival fix, a missing injection, an order test with one history
Self-review with the code-review skill, against kb.pl's doctrine. All three are
tests that passed while the thing they named was not pinned.

F1. `doctrine(inject_the_rival_fix_not_only_the_bug)`. The jcfs test used one
twelve-value fixture, and `take(100)` — raising the cap rather than removing it
— passed the ENTIRE suite. That pins a behaviour, not the rule: the rule is that
detection does not depend on how many values there are, and any fixed fixture
pins one side of some cap. It now runs at 11 and 500 leading values, so every
`take(N)` fails one arm. Renamed to say what it asserts.

F2. `doctrine(an_injection_that_does_not_fail_names_an_untested_path)`.
`an_all_null_column_is_a_label` was never proved able to fail. Under the l1f5
fault it PASSES — an all-null column was Label before the fix too — so its
neighbours' injection says nothing about it. Injecting its own fault (dropping
the `!present.is_empty()` conjunct) fails it alone. The rival fix for l1f5 was
also injected: `is_number() || is_null()` over the full slice repairs Revenue
and leaves Region broken, which is why both `all_` bindings had to move and
`a_blank_value_does_not_demote_a_string_column` is the separating input.

F3. `doctrine(an_order_test_needs_two_histories)`. The cell-order test used one
insertion history five times, which cannot tell "listed its own order" from
"emitted one canonical order that matches this fixture". Same six regions now
imported forwards and backwards, each asserting its own. Reaching the second
direction needed the loop re-ordered under the fault, since an injection that
fails at iteration 1 leaves iteration 2 untested.

Also: `duplicate_csv_headers_not_silent` asserted its two values by map
ITERATION ORDER; now by key, which is also what it means.

Four controls were verified able to fail rather than assumed:
an_ordinary_float_is_still_a_number (float branch deleted),
distinct_headers_are_not_renamed_and_warn_nothing (rename every header),
an_all_null_column_is_a_label (F2), and the long-row control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:15:04 -07:00
Edward Langley 4075d66e6f test(core): cover the partly-pruned group, and pin the collapsed frame exactly
Self-review under the code-review skill, attacking my own three prune tests by
trying to build a wrong prune_axis_items that passes all of them.

The gap it found: all three fixtures put a group in an ALL-or-NOTHING state —
fully collapsed, fully pruned, or intact — so between them they said nothing
about the mixed case, which is the one a user meets first. A group where some
items prune and some survive now has its own test.

a_collapsed_groups_header_survives_pruning now asserts the whole row_items
sequence instead of any(|e| is the Q1 header). An any() cannot see a header
emitted in the wrong position or an extra entry beside it.

The attack itself found no defect, and what it established is worth more than
if it had. Rewriting run_had_item to mean "some item was REJECTED" rather than
"there was an item" is a no-op with the whole workspace green: the header is
pushed eagerly by the FIRST SURVIVING item, so pending_header is already None
before any rejection can be recorded, and the flag is only ever read in runs
where nothing survived — where the two readings coincide. The eager push is
what makes the collapsed/pruned distinction robust rather than delicate.

Also recorded, because it bounds what the new assertion may claim: two
consecutive GroupEnds are not reachable, since expand_category never emits
them, so "no stray GroupEnd" is a property of the writer and not something any
fixture can falsify. The commit that introduced the run_open tracking claimed
it as a virtue; it is one, but it is argued and not tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:14:32 -07:00
Edward Langley e3e89907ea review: pin that only an INVENTED infinity is fixed, and name the None arm
Self-review under the code-review skill, two findings, both narrow.

format.rs: the pikk test asserted a finite number never renders as inf and
that half-away rounding still happens, but said nothing about a genuine
f64::INFINITY. Proved the two assertions ask different questions by clamping
round_half_away to a bare passthrough: the rounding assertion fails (2.5 reads
"2"), the infinity one does not.

test-evidence: named the loop arm that looks reachable and is not -- a pass
computing None where the cache holds Some would spin to the bound and now be
marked circular. The only None evaluate_formula_cell can produce is a WHERE
filter that does not match falling through to aggregate_raw over no data, and
aggregate_raw reads self.data rather than the cache while the formulas do not
change during a recompute, so a Some-to-None transition cannot occur. Also
recorded the free cross-check between two of the fixes: the s08t test asserts
ref:Typo AFTER recompute_formulas, so a formula erroring identically every pass
must be seen to converge rather than be overwritten with circular.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:13:42 -07:00
Edward Langley 4c15f081d3 test(browser): improvise-i0v9's two-tab case, end to end in both engines
The P1's headline scenario was still argued rather than run: the read-only
path's components were verified (the harness probe, the Rust unit tests)
but the two-tabs-one-document flow it was reported as was not.

Two tabs in one context. The assertion that matters is that the second
one PAINTS -- boot succeeds without a handle -- then that it says
read-only and names another tab, then that the reason still stands past
the 8s window rather than being replaced by a watchdog blaming the
browser. `timesArmed() > 0` first, or that denial is vacuous.

Chromium and Firefox, because the exclusivity semantics are the engine's
and the two word the refusal completely differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:55:10 -07:00
Edward Langley a55e7dd5b7 Merge branch 'main' into wave/n-browser-boot 2026-09-21 14:51:37 -07:00
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00
Edward Langley 79da33859a Merge branch 'wave/m-adr001' 2026-09-21 14:47:59 -07:00
Edward Langley efdc47a9dd Merge branch 'wave/j-persistence' 2026-09-21 14:47:56 -07:00
Edward Langley 388f7c6500 fix(project): the mirror settles its mode against every grid it is projected
improvise-el9h. `ClientSession::fold` settled `view.mode` against the grid
that session was LAST projected, and `project_diff` then assigned
`self.grid = desired` and never settled again. The client has the other
shape — `ClientState::fold` settles on every fold, `Command::Project`
included — so for `[SwitchView(records-shaped view), ChangeMode(Normal)]`
the mirror kept `Normal` while the client ended on `RecordsNormal`.

The test came first, as the ticket demands, and failed exactly that way.
`settle_mode` is now called from `fold` and at the end of `project_diff`
once the grid has moved, which also covers the three doors that write the
mirror with bare `reduce_view`: whatever they leave, the next projection
settles.

The fixture's own precondition earned its place on the first run — a view
built with `View::new` carries no axis entry for `_Index`/`_Dim`, so
`is_records_mode` can never fire on one, and without the assertion the
test would have compared two `Normal`s and passed against the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:52 -07:00
Edward Langley 98be603cb9 docs(test-evidence): what this wave's persistence and view tests are evidence of
Keyed by test name per design-principles section 7, recording for each one the
bug it reproduced, the fault injected to watch it fail, and -- where a test has
a neighbour that looks like it -- the input that separates the two.

Three entries are about that separation rather than about a bug:

- a_view_that_does_not_prune_writes_no_prune_line is the direction the default
  supplies, so a writer emitting "prune: true" unconditionally passes the real
  test and fails this one.
- an_interleaved_category_keeps_its_item_order explains why the ticket's own
  two-item reproduction cannot discriminate: with one item of each kind, both
  the right and the wrong order are a single bare run followed by a single
  grouped one.
- slash_in_hidden_name_survives_roundtrip records that improvise-nvq4 does NOT
  reproduce, and is kept as a passing control so the claim cannot be re-filed
  without someone first watching it pass.

core.md is touched for the improvise-qx1z entries, which are View-level and
belong there rather than in formula-and-io.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:32 -07:00
Edward Langley 72b77566c5 fix(web,harness): the demo's second view was the phantom improvise-m4rv removed
CROSSES TWO BOUNDARIES OUTSIDE THIS AGENT'S GROUND, deliberately and reported:
web/demo.improv and harness/tests/contract.spec.js. Dropping the invented
Default view takes the shipped demo from two views to one, and the spec
asserted the phantom in two places -- improvise-m4rv's acceptance criteria
name the spec directly.

The load-bearing half is the improvise-iioi click test, which needs the view
panel to hold two rows so that a click can name a DIFFERENT row than the
cursor does; with one view both readings give the same answer and the test
stops discriminating. The phantom was supplying that second row by accident.

So the demo gains a real second view, Season -- a genuine alternate pivot
(Quarter by measure, paged by Product) rather than an auto-arrangement. Plan
stays first and stays Initial View, so the cursor still starts at index 0 and
tests/web_demo_model.rs is unaffected: every visible cell of the ACTIVE view
is still a Record (gotcha 46). Verified by cargo.

The spec's axis assertion at index 0 moves from the phantom's "R:Product
C:Quarter" to Plan's own "R:Product P:Quarter", derived from
viewmodel::view_axis_summary rather than guessed.

NOT VERIFIED: harness/ has no node_modules and web/pkg is unbuilt here, so
Playwright could not be run. These edits are mechanical substitutions and
need a browser-job run before they are believed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:24 -07:00
Edward Langley c84707261c fix(io): five .improv persistence defects, grammar first
improvise-pnf5 (P1). A cell keyed by the empty CellKey is a legal model state
-- what AddRecordRow leaves while a records row is unfinished -- and format_md
wrote it as " = 0" while coord_list required at least one coordinate. Saving
in records mode therefore overwrote a good document with one the application
could not reopen. Given a spelling rather than discarded, because the ticket
is explicit that legitimate data must not be dropped: data_coords makes the
coordinate list optional and the scalar cell is written "= 42". Unambiguous
under PEG, since bare_name cannot start with "=".

improvise-m4rv. Workbook::new seeds a Default view and on_category_added
fills it, so a file naming only Plan parsed into two views -- the phantom at
index 0, and appended to the user's document on any save. parse_md now drops
Default when the file named at least one view and none was Default. A file
naming no view, or naming Default itself, is untouched.

improvise-3yv3. format_md collected every ungrouped item and emitted the
whole list before any grouped one, so an interleaved category came back
reordered -- and grid axis ordering consumes item insertion order (gotcha 18).
Items are now written in insertion order, a run of bare items sharing a line.

improvise-qu4a. View::prune_empty never reached disk. The line is spelled
with a value rather than by its presence, so the grammar generator can emit
either; absent means false, the compatible reading.

improvise-ppru. Model::measure_agg was lost by the roundtrip. An Aggregation
section carries it, sorted -- measure_agg is a HashMap and hash order is not
an order (gotcha 18), so two saves of one document must be made to agree.

improvise-4lgj. The three wildcard arms in the walker silently skipped any
grammar rule they did not handle, so extending improv.pest caused silent data
loss rather than a loud mismatch. They now bail, and a new exhaustive match
over Rule names the walker that reads each one -- a new rule stops that test
compiling, which is design-principles section 2's "the compiler finds every
call site" applied to a pest-derived enum. It earned its keep immediately by
naming Rule::section, which the first draft had missed.

That arm is also what makes the change safe: the grammar-walking generator
reaches both new rules -- proved by injecting a bail into each and watching
generated_file_parses fail -- so an unwalked rule is now a test failure.

Un-ignores the five reproductions in tests/bug_hunt_reproductions.rs.
improvise-nvq4 did NOT reproduce and is kept as a passing control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:15 -07:00
Edward Langley e1714f3e04 Merge branch 'wave/i-import' 2026-09-21 14:45:57 -07:00
Edward Langley 83d7d4eb72 Merge branch 'wave/k-core-formula' 2026-09-21 14:45:57 -07:00
Edward Langley 5dd203e6a3 fix(core): a view's hidden/collapsed state outlived the thing it named
improvise-qx1z, two arms of one asymmetry.

Arm 2: View::on_category_removed cleared category_axes, page_selections and
hidden_items but not collapsed_groups — the other three lines being right
there is what makes it an omission. Re-adding the category brought the group
back pre-collapsed.

Arm 1: Model::remove_item touches no view at all, so hidden_items kept the
removed name. Re-adding the item put it in the model and the category panel
while leaving it absent from the grid with no indication why. The sweep is
View::on_item_removed, called from Workbook::remove_item over every view —
Workbook is already the stated home of cross-view ops (gotcha 6). It also
answers the third question the ticket asked to check while there: a page
selection naming the removed item goes too.

Both reachable from shipped keys. Each test fails under an injected fault,
and each fixture carries a second item or view so that "forget this item"
is separated from "forget this category".

Also lifts the "Default" view name to view::DEFAULT_VIEW, which parse_md
needs in order to tell a seeded view from one a file asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:45:50 -07:00
Edward Langley ba3e4bd14e fix(core): the Expr match in both eval_bool halves is exhaustive too
A sweep for the SHAPE of improvise-vyrx rather than the site it named. Both
eval_bool and eval_bool_cached also wildcarded the outer Expr match as
`_ => Err("type")`, so a new Expr variant that ought to be usable as a
condition would have become a silent type error in both. Not vyrx's asymmetry
-- the two agreed -- but vyrx's defect: a new variant answered wrongly with
nothing to notice. Adding an Expr::And now fails the build at four sites where
it previously failed at two; verified by adding the variant.

The sweep's other half is which wildcards to LEAVE, recorded in the evidence:
eval_agg's `match inner { Expr::Ref(..) => .., _ => false }` falls through to
the GENERAL path (the per-record fold), which is right for any expression that
is not a bare ref, including one that does not exist yet. The dangerous
wildcard is the one falling through to a refusal or to a specialisation.
CellValue::as_f64 and CategoryKind::of_name clear on the same reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:44:47 -07:00
Edward Langley 424dfb9801 feat(adr-001): deliver clause 4 — a frame applies whole or not at all
improvise-yqpd. `Host::commands` snapshots the document and every session
mirror before a frame and restores both if any command in it fails, so a
chain whose later mutation cannot run leaves the document exactly as its
stated basis described.

Two failure signals, because a command can fail two ways. An effect that
sets `abort_effects` is `Rejection::MutationRefused`. A command whose
record no longer resolves is `Rejection::ChainBroken` — and that is the
half the ticket costed wrongly. It argued that interleaving the record
check with the applies would close clause 4's silence but would sell the
legitimate double-write. That is true of the record tier's COUNTER half
and not of its STRUCTURAL half: writing a record twice cannot make it
stop resolving. So the tier splits — "does it resolve" is asked again
before every mutation, "is its counter what you claimed" stays pre-frame
— and clause 4 gets its loud failure at no cost to clause 2.
`a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 still
passes, which is the guard that the trade was not taken by accident.

The snapshot primitive is `App::snapshot_document`/`restore_document`, so
a future RunScript opens the same transaction; only the trigger is at the
frame. It is deliberately NOT taken inside `apply_effects`, which runs on
every keypress — an unconditional Workbook clone there would put a full
document copy on the held-key path.

`a_frame_whose_later_mutation_fails_is_not_yet_undone` is inverted to
`..._is_undone_whole`; all three assertions flip and a fourth is added
that the record counter goes back too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:44:17 -07:00
Edward Langley 000de09c9b Merge branch 'wave/k-core-formula' 2026-09-21 14:43:30 -07:00
Edward Langley dcc0addfb4 test(import): un-ignore five reproductions and record what each is evidence of
The five reproductions for improvise-jc95, 5hfh, l1f5, 2cw9 and the long-row
half of k8i now run. Removing `#[ignore]` is part of the fix: an ignored
reproduction is an open bug, and the module doc now says so rather than claiming
the file is report-only.

Three of the five asserted warning PROSE against a module that emitted no
warnings at all, so they specified wording with no convention to match — and
`"overwritten"` does not contain `"overwrite"`, so the most natural wording
would have failed its own assertion. Each now asserts the OBSERVABLE outcome
first and the prose only as a companion:

- duplicate_csv_headers_not_silent: three fields with values 1, 2, 3 in order.
- repeated_import_coordinates_not_silently_overwritten: exactly one cell at 30.0.
- long_csv_row_is_not_silently_dropped: no row discarded, plus a warning that
  counts the affected rows and names the widest, because the values here really
  are still dropped and there is no observable outcome to assert instead.

Every fix was verified by injecting its own fault and watching the test fail:
the `unwrap_or(0)` restored, `disambiguate_headers` short-circuited, the
long-row counter disabled, both `all_` bindings restored to the full slice,
`take(10)` restored, the accumulate arm changed back to last-wins, and the cell
write order sorted. Each named test failed; each control did not.

Evidence in context/test-evidence/formula-and-io.md, keyed by test name,
including the correction to `the_detected_date_format_does_not_depend_on_the_hash_seed`
— its fixture had made the ten-sample truncation a specification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:43:19 -07:00
Edward Langley e3ecd21c3a fix(import): repeated coordinate tuples are summed, not overwritten
improvise-2cw9 (P1): each record called `set_cell` directly, so two source rows
with identical category values were one cell and the second overwrote the first.
`Region,Revenue / East,10 / East,20` imported as 20 with no warning; the first
row simply did not exist afterwards.

The policy was deliberately left open by the ticket. The model cannot PRESERVE
both — a cell is named by its coordinate tuple and there is no second address —
so the lossless options are aggregating or inventing a row-identity category,
and the latter changes the shape of every imported document. Summing is what a
pivot table does to repeated records and is `measure_agg`'s own default. The
warning is what keeps it from being silent, because summing is NOT right for a
measure the user later sets to Avg; if that case matters the follow-up is a
wizard step that asks, not a change to this default.

The accumulator is a `Vec` with a `HashMap` beside it that only deduplicates:
writing the cells in hash order would have been gotcha 18's bug in a third
place, introduced by the fix for a different one. `set_cell` writes into an
IndexMap whose order persistence depends on, so the same CSV would have produced
a different .improv per process. Pinned by a five-iteration test.

The collision label is `CellKey`'s own Display, not a second formatter.

Evidence in the following commit, keyed by test name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:43:12 -07:00
Edward Langley 79b8501a66 fix(import): infer a field kind from the values that are present, over all of them
improvise-l1f5: `all_numeric` and `all_string` ran over every value including
`Value::Null`, and `parse_csv_field` maps a blank CSV field to Null. One blank
cell in a numeric column therefore proposed Label rather than Measure, and on a
default import the numbers arrived as category item NAMES rather than as cell
data. Both bindings now run over the present values; the `!is_empty()` guard is
what keeps an all-null column from satisfying a vacuous `all()`, and it falls
through to Label deliberately.

improvise-jcfs: `detect_date_format` sampled the first ten values, so a date
that can only be read day-first was never seen if it appeared later, and the
column silently imported month-first with every derived _Month/_Quarter wrong.
The cap is gone; `all()` short-circuits, so a format that does not fit still
costs one parse.

`the_detected_date_format_does_not_depend_on_the_hash_seed` expected
`%m/%d/%Y` and now expects `%d/%m/%Y`. Its fixture was built so the
disambiguating value sits OUTSIDE the ten-sample window, so an assertion written
to prove determinism had made the neighbouring defect a specification. Nothing
about hash-seed independence changed; only the constant did.

Evidence in the following commit, keyed by test name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:43:04 -07:00
Edward Langley 5a0ba067e3 fix(import): a nonfinite CSV token is not zero, and a repeated header is not one column
improvise-jc95 (P1): `serde_json::Number::from_f64` has no answer for NaN or the
infinities, and `parse_csv_field` answered that `None` with
`.unwrap_or(Number::from(0))`. So `NaN`, `inf`, `-inf` and `1e999` imported as a
real measured zero — not a blank, not an error, a number that sums. The token
now falls through to the existing string arm.

improvise-5hfh: headers key a `serde_json::Map`, so `A,B,A` wrote the third
column over the first under one key and the record kept two fields where the
file had three. Later occurrences are renamed to a suffix nothing else in the
file claims, and each rename is reported.

Also the half of improvise-k8i (closed) that its fix did not reach: a row LONGER
than the header had every extra field dropped by `headers.get(i)` returning
None, silently. The values are still dropped — preserving them means inventing
column names, which is a wizard decision — but the parser now counts the rows,
names the header width and reports the widest row seen.

Evidence in the following commit, keyed by test name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:42:59 -07:00
Edward Langley 20030cac0d fix(core): four evaluator and display bugs, and one exhaustive match
improvise-s08t: eval_agg narrowed its partial key only where
find_item_category resolved, so SUM(Typo) left the key unconstrained and
aggregated every numeric measure matching the context. An unknown name now
errors ref:{name} before the raw fast path.

improvise-s2w3: the same fast path assumed a bare Ref names a STORED cell. A
formula target has no raw cell behind it (improvise-nrs), so SUM(Double)
summed nothing and answered 0 where SUM(Double + 0) folded and answered 20.
core.md §13's defence of the specialisation holds — what failed is its
premise — so eval_agg asks formula_for, the one home of "is this cell
derived", and a derived ref falls into the fold.

improvise-7iww: recompute_formulas stopped at MAX_EVAL_DEPTH and left the last
iteration in formula_cache, so a circular formula read Error("circular")
directly and Number(26) through the cached path — which is the DISPLAYED path,
since default views put the virtual categories on Axis::None. Cells still
moving when the bound runs out are now marked circular, which is the same
bound evaluate_depth already enforces.

improvise-vyrx: eval_bool_cached wildcarded BinOp where eval_bool lists its
arms. Verified by adding a BinOp::Mod: four sites now fail the build where
three did before.

improvise-pikk: round_half_away scaled by 10^decimals before truncating, so
format_f64(1e307, false, 2) rendered "inf" for a finite number.

improvise-f86q: add_category checked MAX_CATEGORIES before contains_key, so
re-adding an existing name at the limit was refused with a false message — and
per gotcha 21 aborted the rest of the batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:42:19 -07:00
Edward Langley e383aac076 fix(formula): the walker spells every agg_func and cmp_op, and refuses the rest
improvise-492x. parse_agg_func ended `_ => AggFunc::Count` and parse_cmp_op
ended `_ => BinOp::Ge`, so a new alternative in formula.pest would have parsed
as COUNT or as >= with no error: MEDIAN(Revenue) evaluating as a count.

One of three beads that are the same defect (with improvise-vyrx and
improvise-4lgj); design-principles §2 is the rule all three break. A compile
error is not on offer here, because agg_func is one pest rule over five string
alternatives and pest_derive generates no per-alternative Rule — so the walker
gets the idiom this file already uses for a grammar/walker desync,
GRAMMAR_INVARIANT, and the accepted set is written down in Rust as a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:42:19 -07:00
Edward Langley 09e43c1fcc fix(command,protocol): cap a column width while it is still usize
improvise-yae4. grid_body narrowed every measured width to u16 and only then
added the one-character gap and clamped to MAX_COL_WIDTH, so the arithmetic ran
at full width before the cap that makes it safe. A stored 65535-column text
value panicked at `w + 1` in debug; in release the sum wrapped to 0 and the
column was drawn at MIN_COL_WIDTH — the narrowest column, for the widest value.
A 65536-column value never reached the addition: `as u16` truncated it to 0
first, so it wrapped in debug builds too, silently.

Widths are now capped while still usize, in the one place every measurement
enters (`widen`), which is why header_level_seed folds through it rather than
keeping a second `as u16`. The answer is unchanged for every input that did not
overflow: (min(w,32) + 1).clamp(5,32) and (w + 1).clamp(5,32) agree everywhere,
both saturating at 32 for any w >= 31.

improvise_protocol::header_level_widths carried the identical defect on the
row-header axis — a 65535-column item label — and is fixed the same way. It is
the one home for that rule (gotcha 44), so the row header and the column widths
cannot end up with two answers.

wide_cell_clamps_without_overflow is un-ignored and extended to the 65536 arm
and to an ordinary 20-column value. It asserts `widths == vec![expect]` rather
than `all(<= 32)`: the length pins that the fixture really produces ONE column,
and the ordinary arm pins that the single column is the one carrying the value —
a fixture in which no column was wide would read MIN_COL_WIDTH there and pass a
`<= 32` check. Both injected faults are recorded in
context/test-evidence/core.md.

Not fixed, and out of this ticket's scope: compute_visible_cols accumulates
`acc + w` in u16, reachable only from a terminal ~65535 columns wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:42:08 -07:00
Edward Langley f47297c3a7 fix(core): a collapsed group's header survives pruning, and so does its GroupEnd
improvise-lhi4 and improvise-u6gc: two defects in one match block in
prune_axis_items, pulling in opposite directions. The naive repair of the
first deepens the second, so they are fixed together.

lhi4: a collapsed group contributes ZERO AxisEntry::DataItems — expand_category
emits the GroupHeader and then continues past every item — so a keeper that
kept a header only if a data item under it survived always dropped it. With
prune_empty on, the group left the grid entirely and `z` could not reach it:
collapse became one-way, recoverable only through :toggle-group.

u6gc: dropping a pruned header's GroupEnd re-opens an EARLIER run, so the
nearest-preceding walk in row_group_for/col_group_for — and AxisFrame::group_of
on the wire — answered with a group that had already ended. That is exactly the
inheritance improvise-gd83 added GroupEnd to prevent, so gd83 was regressed
whenever prune_empty was on.

The two emptinesses are distinguishable from the entry stream alone, so the
keeper needs no &View (which GridLayout has already dropped): a run holding no
DataItem at all was never expanded — it is collapsed, and its header stays — a
run whose items were all rejected is genuinely empty and goes. A GroupEnd is
kept whenever a run is open in the output, which closes a re-opened earlier
header even though the header between them was pruned.

gotcha 1's shape is unchanged: still a predicate over the data index, no flag
vector, no parallel length a caller could size wrongly.

Three tests, because each is satisfied by a fix that misses another;
a_header_whose_items_all_prune_away_is_dropped passes before the change and is
the discriminator lhi4's acceptance criteria ask for. Evidence, including the
three injected faults and which pair each one fails, is in
context/test-evidence/core.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:42:00 -07:00
Ed Langley 4f194e9f26 Merge pull request #104 from fiddlerwoaroof/bd-updates
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / announce (push) Has been cancelled
Bd updates
2026-09-21 14:33:48 -07:00
Edward Langley 415d43d3ce Merge branch 'main' of github.com:fiddlerwoaroof/improvise 2026-09-21 14:33:08 -07:00
Ed Langley c899b8aef4 Merge pull request #103 from fiddlerwoaroof/bd-updates
Bd updates
2026-09-21 14:33:01 -07:00
Edward Langley ebc89b7a57 fix(beads): repair 11 dependency edges stored with the flag as part of the id
`bd dep add X blocked-by:Y` passes "blocked-by:Y" as the POSITIONAL
depends-on-id, and bd stores it without validating that the id exists. So all
11 edges the groom added pointed at non-existent issues, resolved to nothing,
and every bead I had just blocked still reported READY -- bd ready said 100
when it should have said 94.

Correct syntax is `bd dep add X --blocked-by Y` or the bare positional
`bd dep add X Y`, with -t for a non-blocks type.

Caught by verifying the ready list before dispatching a wave against it, which
is the only reason it did not become six agents working on blocked tickets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:32:34 -07:00
Edward Langley 660daf1bac Merge branch 'main' of github.com:fiddlerwoaroof/improvise 2026-09-21 14:29:40 -07:00
Edward Langley 700879de20 chore(beads): groom the backlog
Three duplicates closed, each filed independently by a different reviewer on
the same day, each carrying its unique content over to the survivor rather
than being dropped:
  eiv2 -> jc95  (CSV nonfinite becomes zero; jc95 raised P2 -> P1, since the
                 duplicate's P1 was the better rating and PR #101's
                 reproduction already cites jc95)
  dhw5 -> l1f5  (a blank numeric CSV field types the column as Label)
  16af -> i0v9  (OPFS handle contention; the duplicate's single-tab RELOAD
                 trigger is now recorded on i0v9, which had only driven the
                 two-tab case)

jkxr closed -- fixed on main today, by injection.

New: improvise-1wft. Five beads from three reviewers turned out to be one
failure mode -- the browser's boot fails for several reasons and the page says
nothing specific or nothing at all, because no channel carries a boot failure's
REASON from the worker to the page. i0v9/86nw/9cma/iobc/vep8 now block on it so
they are not fixed five different ways.

improvise-bck read READY -- every dependency closed -- while its own criterion
(1) was failing. Now blocked on 1wft and p7lh, with a note that the criterion
says Chrome AND Firefox while the shell spec is Chromium-only by design.

Two more clusters recorded on their members so they dispatch as single units:
the three `_ =>` wildcard beads (492x/4lgj/vyrx, a straight design-principles
section 2 violation) and the five vacuous-assertion beads (lm5e/gjat/ub4o/
sfwy/a36c), the latter pointed at jkxr as the worked example.

pcje narrowed and retitled: p7lh owns totals and the page bar, which is the
demonstrated half and a bck blocker. lhi4/u6gc paired -- same match block, and
u6gc regresses gd83 whenever prune_empty is on.

h074 reclaimed: an expired lease, heartbeat 17 hours dead.

Audited: all 14 deferred beads carry a recorded reason. A first pass reported
four without one; that was a false positive from reading only `notes` when the
reason was in `description`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:28:40 -07:00
Ed Langley 8536d5ed40 Merge pull request #102 from fiddlerwoaroof/bd-updates
Bd updates
2026-09-21 14:28:28 -07:00
Edward Langley 0f0c38b50d chore: update issues.jsonl 2026-09-21 14:24:34 -07:00
Edward Langley 61a3509185 chore: regenerate issues.jsonl from the database
The previous export raced improvise-qjy2's creation. Regenerated so the file
matches the database, which is the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:23:24 -07:00
Edward Langley cd8f3e3ec0 docs: a beads-merge skill, and the export the driver test regenerated
The skill covers the four things that bit us today: the per-clone git config
the driver needs (.gitattributes names it, but the driver lives in git config,
which a checkout does not carry -- without it git silently line-merges), how to
recover a file that already has markers in it, where genuine same-issue
divergence is reconciled (bd conflicts, against Dolt -- not reconstructed from
the JSONL), and the autostash trap that writes markers without anyone running
`git stash`.

Driver verified against a real conflict: plain text merge produced 3 marker
lines and 3 invalid JSONL lines; the driver produced 470 rows, 0 invalid, and
left the database byte-identical (470 rows before and after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:22:58 -07:00
Edward Langley c5e77591d3 tooling: a merge driver for .beads/issues.jsonl
issues.jsonl is a passive export whose rows are ordered by insertion, so two
sides that file issues collide in the same region even when the issues are
disjoint. Git line-merges it and produces conflict markers INSIDE the JSONL,
which breaks bd's auto-export silently -- observed today, where a stash-pop
left `<<<<<<< Updated upstream` in the file and auto-export was skipping with
"invalid character '<'" until the file was regenerated.

The driver does no merge logic of its own, because bd already has the
semantics: `bd import` skips any row whose updated_at is not strictly newer
than the local one, creates ids it has not seen, and keeps the local row on a
tie. So importing BOTH sides cannot move the database backwards, and the file
is then regenerated from it. The database is the truth; the file is derived.

Deliberately not reimplemented here: field-level reconciliation. `bd conflicts
show/resolve` already does that against the Dolt working set, which is the real
sync path -- a second copy against the text export would have to track bd's
schema by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:22:08 -07:00
Ed Langley 2e631cdfc8 Merge pull request #101 from fiddlerwoaroof/codex/bug-hunt-reproductions
test: preserve bug-hunt reproductions
2026-09-21 14:21:52 -07:00
Edward Langley a36b5a63f7 test: expand overwrite warning patterns in reproduction test
Expand the warning pattern matching to include various forms of the
overwrite warning, such as 'overwritten', 'overwriting', and 'overwrote',
while still ensuring the affected coordinates are present.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL)
2026-09-21 14:15:58 -07:00
Edward Langley bc9ecaf527 test: improve assertions in bug hunt reproductions
Refine assertions in reproduction tests to be more specific and ensure they
correctly validate expected error messages and warnings.

This includes improvements to:
- duplicate_csv_headers_not_silent
- repeated_import_coordinates_not_silently_overwritten
- long_csv_row_is_not_silently_dropped
- wide_cell_clamps_without_overflow

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL)
2026-09-21 14:08:28 -07:00
Ed Langley c8bd943b51 Merge branch 'main' into codex/bug-hunt-reproductions 2026-09-21 14:03:35 -07:00
Edward Langley e4adf86c1a style: cargo fmt the assertion 2b086b2 added
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
2b086b2 landed an assert_eq! that rustfmt wraps, and `cargo fmt --all --check`
is the first step of the host CI job -- so every step after it was SKIPPED,
including the test run. PR #101 inherited the red through its merge commit
while its own file is formatted correctly.

Run the formatter before committing, not after CI says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:02:05 -07:00
Edward Langley 0d13ea5f51 test: ignore known bug reproductions by default
These tests reproduce known bugs and are marked with #[ignore] to prevent
them from failing standard test runs.

They can be run explicitly using 'cargo test --test bug_hunt_reproductions
-- --ignored --nocapture'.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL)
2026-09-21 13:59:38 -07:00
Edward Langley 8c84fd95ea test: preserve bug-hunt reproductions
Preserves a collection of test cases designed to reproduce specific bugs in
the `improvise` and `improvise_io` libraries. This includes issues related
to:
- CSV parsing
- Non-finite values
- Circular formulas
- UI overflow from large strings

Twenty tests in this file are intentionally designed to fail to reproduce
known unfixed bugs, while two control tests are expected to pass.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL)
2026-09-21 13:55:03 -07:00
Ed Langley 56ee496d30 Merge pull request #100 from fiddlerwoaroof/cursor/adversarial-review-2026-09-21-5eff
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Adversarial code review 2026-09-21
2026-09-21 13:51:26 -07:00
Edward Langley 5f47fecf05 chore(merge): gh/main — PR #100 adversarial review findings
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
# Conflicts:
#	.beads/issues.jsonl
2026-09-21 13:50:16 -07:00
Edward Langley 2b086b205a test: parse_duplicate_categories could not fail for the merge it names
Found by an external Grok Bot adversarial review (improvise-jkxr), verified by
injection rather than taken on report.

The test parses two `## Category: Type` sections -- `- A` then `- B` -- and
asserted only `!item_names.is_empty()`. push_category concatenates on a
duplicate section name, and a merge keeping EITHER half alone still leaves the
list non-empty, so only a total wipe could ever have failed it.

Fault injected: `existing.items.extend(items)` -> `existing.items = items`, so
a second section replaces the first. The old assertion PASSED; the new one
fails with left ["B"], right ["A", "B"].

Pins order as well as membership -- insertion order is load-bearing (gotcha 18)
-- matching the standard its neighbour already holds groups to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 13:49:58 -07:00
Grok Bot (Ed Langley) 2705c16cab beads: file PR #100 adversarial review findings
24 new beads from cloud adversarial review PR #100 (P1–P3). No application
code changes. Map: F01–F05 P1, consolidated P2/P3 transport and ws-server,
F21 noted as dup of iobc.
2026-09-21 13:49:44 -07:00
Edward Langley d358da46a1 chore(merge): gh/main — Grok Bot adversarial findings (jkxr, df2w)
# Conflicts:
#	.beads/issues.jsonl
2026-09-21 13:47:48 -07:00