Skip to content

char_at is quadratic and unroutable: the ascii-aware repair's precomputed flag names an RcStr carrier that does not exist - #9212

Merged
briansrls merged 3 commits into
mainfrom
session/gentle-owl-527
Aug 25, 2026
Merged

briansrls merged 3 commits into
mainfrom
session/gentle-owl-527

Conversation

@briansrls

@briansrls briansrls commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

What

v1_rt::char_at_ascii_aware / string_length_ascii_aware / substring_ascii_aware take a precomputed is_ascii flag, and their doc comment names the producer of that flag as "the RcStr carrier fact". No RcStr exists in this tree: Value::Str is Rc<str>, and every caller — the three wrappers plus the one interpreter call site — supplies s.is_ascii(), an O(n) whole-string rescan computed fresh on every call. The split is unroutable: three public functions whose parameter has no producer, under a comment asserting a carrier that does not exist.

Provenance (the obvious reading is wrong)

Nobody deleted RcStr. b1775d8e44 (#8360) landed both halves and is not an ancestor of main. main's history is rooted at 67437fcbe9 (#8833) — a wholesale seed re-import whose tree already carries the runtime split (src/v1/runtime_rust.dag +826) beside an interpreter with zero RcStr occurrences (+16717). The flag has never had a producer anywhere in main's reachable history, and #8360's O(1) claim has never been true of this tree.

The repair

At the single authority src/v1/runtime_rust.dag; the generated mirrors follow by regen.

  • Delete the _ascii_aware triplet. Nothing supplies a flag other than s.is_ascii(), so the parameter is a second representation of a fact the function can read itself (§2/§3), and the comment on it is the §4b inflation case — a carrier named for a rung the tree does not occupy.
  • Bound the ASCII test by the requested index instead of by the whole string. A leading run of ASCII bytes makes the byte offset equal the code-point offset, so char_at examines bytes[..=pos] and substring examines bytes[..end], never the tail. Cost drops from O(n) + O(pos) to O(min(pos, n)) — the fallback's own cost.
  • Semantics unchanged. Where the old form fell back to chars() because the string contained a multibyte char, the new form takes the byte path only when the prefix up to the requested index is ASCII — exactly the condition under which byte index equals code-point index.
  • Route the interpreter's native_len Value::Str arm through v1_rt::string_length, and fix its doc comment, which named the deleted helper.
  • Delete gunbc.char_at_scaling_probe_support. Its DissolutionCondition names src/v1/stage0/src/bin/char_at_scaling_probe.rs, deleted by Delete unrostered seed probes and orphan tests #9160, and its trigger is "when char_at's O(1) property is floor-enrolled" — a property this tree does not have. Unconsumed (census row in docs/plans/unconsumed-module-residue-disposition.md), subject gone.

Residual — declared, not claimed closed

A left-to-right walk of a string is still O(n²), now with a smaller constant rather than a different shape. A single call cannot be O(1) without the whole-string ASCII fact, which needs a carrier on the string value. That carrier — or a cursor surface that does not re-index from zero — is this class's next-rung trigger, and it is recorded in the char_at doc comment rather than left to be rediscovered. Re-landing the RcStr carrier is a substantial change to the v1 seed and is deliberately not folded in here.

This change lowers no rung: the deleted split was inert, so nothing it guaranteed is lost.

Test plan

Witness: dag/test/claim/char_at_unicode_witness_test.dag (SubstrateInputsOnly, floor-routed), gaining the discriminating control for the prefix-bounded path — "ab" + U+00E9 + "c" is 5 bytes and 4 code points, so a byte-offset implementation returns U+00A9 at index 3 and "c" at index 4 where code-point indexing returns "c" at 3 and "" at 4.

Run remotely (BuildBuddy), gunbc run --claim-run --function <f> per test:

function repaired char_at prefix check removed
char_at_indexes_code_points_on_multibyte_text PASS RED
char_at_byte_offset_would_mismatch_at_index_one PASS PASS
string_length_counts_code_points_not_bytes PASS PASS
char_at_agrees_across_the_ascii_prefix_boundary (new) PASS RED
char_at_past_the_multibyte_char_is_not_a_byte_offset (new) PASS RED

The perturbation binary was deleted and rebuilt from scratch, the build failure-checked rather than piped through tail, and the edit grep-proven in source, so these arms are not a stale binary answering twice.

One assertion was written and then removed — read this before assuming substring is covered

A third new assertion, substring_agrees_across_the_ascii_prefix_boundary, did not discriminate and is not in this PR. Its own perturbation — if bytes[..out_end].is_ascii() replaced by if true, taking the byte-slice path unconditionally — left it GREEN, twice, the second time under the same rebuilt-from-scratch controls. A follow-up probe that would have printed the returned values panicked in cli_run.rs on both arms, so it measured nothing; identical output across arms is the signature of an instrument that did not run, not of agreement.

It was removed rather than kept with a caveat: a check whose RED has never been observed is not coverage, and one named after the thing it does not test is worse than absent. The witness module now says so in its own note, so the disclaimer travels with the test rather than living only here.

char_at_byte_offset_would_mismatch_at_index_one (pre-existing, not authored or changed here) also fails to discriminate against this perturbation — at byte index 1 of "aéb" the byte is 0xC3, which renders 'Ã', not the '©' that assertion names.

Open question, recorded rather than routed around: substring(s, 0, 3) on "ab" + U+00E9 + "c" under the unconditional byte path slices s[0..3], which lands inside the two-byte U+00E9 and should panic on a non-char-boundary. It did not. Either that path is not reached by a named-argument .dag call, or the panic is absorbed between the interpreter arm and the claim runner's exit status. The second would be the more serious finding — a witness that cannot go red because failures are swallowed would affect every witness, not this one — and it deserves its own lane. The substring code change here stands on the semantics-preserving argument plus heavy existing corpus exercise, and explicitly not on a discriminating control of its own.

Also: cargo fmt --all --check clean; release build of the edited runtime clean.

Regen (two passes, both run)

v1_rt.rs is emitted by v1_compiler_runtime_rust.rs, which is itself the stage0 transliteration of runtime_rust.dag — so a single pass cannot converge both. Pass 1 (claim_executor --required-regen) rebuilt the mirror from the edited authority and reported first_generation_equal=false ... FAIL generated surface drift: v1_compiler_runtime_rust.rs, v1_rt.rs; that candidate mirror is installed in the second commit. Pass 2, rebuilt against it:

required-regen: first_generation_equal=true planned=135 executed=135 declared_divergent=1 [main.rs]
=== PASS2 DIFF candidate vs committed ===
SAME v1_rt.rs
SAME v1_compiler_runtime_rust.rs

Both generated mirrors are now the authority's direct, converged output — the hand-written v1_rt.rs in commit 1 is byte-identical to what the emitter produces. (declared_divergent=1 [main.rs] is pre-existing and untouched by this change.)

Brian Searls added 3 commits August 25, 2026 18:18
…und the ASCII test by pos

`v1_rt::char_at_ascii_aware` / `string_length_ascii_aware` /
`substring_ascii_aware` take a precomputed `is_ascii` flag, and their doc
comment names the producer of that flag as "the `RcStr` carrier fact". No
`RcStr` exists in this tree: `Value::Str` is `Rc<str>`, and every caller --
`char_at`, `string_length`, `substring`, and the one interpreter call site --
supplies `s.is_ascii()`, an O(n) whole-string rescan computed fresh per call.
The split is therefore unroutable: it is three public functions whose
parameter has no producer, plus a comment asserting a carrier that does not
exist.

Provenance, because the obvious reading (someone deleted RcStr) is wrong:
b1775d8 (#8360) landed BOTH halves and is NOT an ancestor of main. main's
history is rooted at 67437fc (#8833), a wholesale seed re-import whose tree
already carries the runtime split (`src/v1/runtime_rust.dag` +826) beside an
interpreter with no `RcStr` (+16717, zero occurrences). The flag has never had
a producer anywhere in main's reachable history, and #8360's O(1) claim has
never been true of this tree.

The repair, at the single authority `src/v1/runtime_rust.dag` (mirrors
`v1_rt.rs` / `v1_compiler_runtime_rust.rs` follow by regen):

- Delete the `_ascii_aware` triplet. Nothing supplies a flag other than
  `s.is_ascii()`, so the parameter is a second representation of a fact the
  function can read itself (DESIGN §2/§3), and the comment on it is the §4b
  inflation case -- a carrier named for a rung the tree does not occupy.
- Bound the ASCII test by the requested position instead of by the whole
  string. A leading run of ASCII bytes makes the byte offset equal the
  code-point offset, so `char_at` examines `bytes[..=pos]` and `substring`
  examines `bytes[..end]`, never the tail. Cost drops from
  O(n) + O(pos) to O(min(pos, n)) -- the fallback's own cost. Semantics are
  unchanged: where the old form fell back to `chars()` because the STRING
  contained a multibyte char, the new form takes the byte path only when the
  PREFIX up to the requested index is ASCII, which is exactly the condition
  under which byte index equals code-point index.
- Route the interpreter's `native_len` `Value::Str` arm through
  `v1_rt::string_length` and fix its doc comment, which named the deleted
  helper.
- Delete `gunbc.char_at_scaling_probe_support`. Its `DissolutionCondition`
  names `src/v1/stage0/src/bin/char_at_scaling_probe.rs`, deleted by #9160,
  and its trigger is "when char_at's O(1) property is floor-enrolled" -- a
  property this tree does not have. It is unconsumed (census row in
  docs/plans/unconsumed-module-residue-disposition.md) and its subject is gone.

RESIDUAL, DECLARED RATHER THAN CLAIMED CLOSED: a left-to-right walk of a
string is still O(n^2), now with a smaller constant rather than a different
shape. A single call cannot be O(1) without the whole-string ASCII fact, which
needs a carrier on the string value; that carrier -- or a cursor surface that
does not re-index from zero -- is this class's next-rung trigger, and it is
recorded in the `char_at` doc comment rather than left to be rediscovered.
This change lowers no rung: the deleted split was inert, so nothing it
guaranteed is lost.

Evidence: dag/test/claim/char_at_unicode_witness_test.dag (SubstrateInputsOnly,
floor-routed) gains the discriminating control for the prefix-bounded path --
"ab" + U+00E9 + "c" is 5 bytes and 4 code points, so a byte-offset
implementation returns U+00A9 at index 3 and "c" at index 4 where code-point
indexing returns "c" at 3 and "" at 4, with the same split applied to
`substring`.
…ust.dag authority (pass 1)

Pass 1 of the stage0 two-pass convergence. `--required-regen` regenerated
`v1_compiler_runtime_rust.rs` -- the stage0 transliteration of
`src/v1/runtime_rust.dag` -- from the edited authority; this installs that
candidate byte-for-byte. `v1_rt.rs` is emitted BY this mirror, so its pass-1
candidate was still the old `_ascii_aware` text and converges only on pass 2,
after a rebuild against the mirror installed here.
…t coverage

The witness gained three assertions for the prefix-bounded fast path. Two of
them are demonstrated discriminating: with `char_at`'s prefix check removed and
the binary rebuilt from scratch (perturbation confirmed present in source),
`char_at_agrees_across_the_ascii_prefix_boundary`,
`char_at_past_the_multibyte_char_is_not_a_byte_offset` and the pre-existing
`char_at_indexes_code_points_on_multibyte_text` all go RED.

The third, `substring_agrees_across_the_ascii_prefix_boundary`, does not. Its
own perturbation -- `if bytes[..out_end].is_ascii()` replaced by `if true`, so
the byte-slice path is taken unconditionally -- left it GREEN, twice, the
second time with the binary deleted first, the build failure-checked rather
than piped through `tail`, and the edit grep-proven in source. A follow-up
probe that would have printed the returned values panicked in `cli_run.rs` on
BOTH arms, so it measured nothing: identical output across arms is the
signature of an instrument that did not run, not of agreement.

So the mechanism is unexplained. What is NOT in doubt is the assertion's
status: a check whose RED has never been observed is not evidence, and
shipping it would put it in the worst class DESIGN §4b names -- permanently
green as far as anyone can show, and cited as coverage precisely because it is
named after the thing it does not test. It is removed rather than kept with a
caveat, because a caveat in a PR body does not travel with the test.

The `substring` code change stands: it is the same prefix-bounding as
`char_at`, semantics-preserving by the same argument (the byte path is taken
only when the prefix up to the requested index is ASCII, which is exactly when
byte index equals code-point index), and substring is exercised heavily by the
existing corpus. What it does not have is a discriminating control of its own,
and the witness note now says so in the module rather than leaving a reader to
infer coverage from the file's name.

OPEN QUESTION, recorded rather than routed around: `substring(s, 0, 3)` on
"ab" + U+00E9 + "c" under the unconditional byte path slices `s[0..3]`, which
lands inside the two-byte U+00E9 and should panic on a non-char-boundary. It
did not. Either that path is not reached by a named-argument `.dag` call, or
the panic is absorbed somewhere between the interpreter arm and the claim
runner's exit status. The second would be the more serious finding -- a witness
that cannot go red because failures are swallowed would affect every witness,
not this one -- and it is worth its own lane.
@gunbai-bot
gunbai-bot Bot marked this pull request as ready for review August 25, 2026 19:23
@briansrls
briansrls merged commit 7e58876 into main Aug 25, 2026
1 check passed
@briansrls
briansrls deleted the session/gentle-owl-527 branch August 25, 2026 23:06
briansrls pushed a commit that referenced this pull request Aug 26, 2026
…ried ASCII fact instead of testing per call (#9256)

* Re-land the RcStr carrier: the interpreter's string index reads a carried ASCII fact instead of testing per call

#9212 deleted the `_ascii_aware` triplet in `v1_rt` because its precomputed
`is_ascii` parameter had no producer anywhere in main's reachable history --
the doc comment named "the `RcStr` carrier fact" and no `RcStr` existed. It
closed with a declared residual: a single call cannot be O(1) without the
whole-string ASCII fact, that fact needs a carrier on the string value, and
that carrier is the class's next-rung trigger. This lands the carrier, so the
parameter's absent producer is not re-created -- the fact lives on the value.

`v1_rt::RcStr` (authored at `src/v1/runtime_rust.dag` `rt_string_carrier`) is
`Rc<str>` plus the ASCII flag, computed once in `RcStr::new` and read by
`RcStr::char_at` / `substring` / `string_length`. The flag has exactly one
producer and a private field, so "carrier says ASCII, content is not" has no
constructor (DESIGN §4b: structurally impossible, not validated). The free
`char_at`/`substring`/`string_length` stay exactly as #9212 left them -- they
are the entry points for emitted code holding a bare `&str`, which has no fact
to read and must test; the carrier methods fall back to them whenever the flag
is false, so semantics are theirs by construction.

`v1_interpreter.rs` carries `Value::Str(RcStr)` and routes every string
position primitive through it: `s[i]` (`eval_index`), `s[a..b]`
(`eval_slice`), `.char_at()`, `.substring()`, `char_at()`, `substring()`,
`string_length()`, `length()` and `native_len` -- eight arms plus the length
helper, over three primitives. `expect_value_str` hands those arms the carrier
itself rather than an owned `String`, so a read-only index no longer pays an
O(n) `.to_string()` either.

WHAT THE COST CHANGE IS, at the honest grain: a single ASCII index goes from
O(min(pos, n)) to O(1), so a left-to-right walk over an ASCII string goes from
O(n^2) to O(n). It is NOT constant-time for non-ASCII text -- the carrier
answers only "is the byte offset the code-point offset", and where it is not,
the walk is the same `chars()` walk as before. The remaining residual is the
re-index-from-zero shape itself; a cursor surface is its next rung, and the
`char_at` doc comment now says that instead of naming a carrier that does not
exist.

Evidence, by execution (remote, `cargo test -p v1-compiler --lib`):
`v1_interpreter::rc_str_carrier_tests` -- 5 passed, 0 failed. Four of them are
differential against the free functions the carrier shadows (the pre-carrier
semantics), over every index of a 5-byte/4-code-point string, so a carrier that
took the byte path over multibyte text disagrees at the first non-ASCII code
point. The fifth states that string's code-point answers absolutely, so the
control survives a change to the free functions.

The floor witness `test.claim.char_at_unicode_witness` now runs through the
carrier: its `char_at`/`string_length` calls reach `free_call.char_at` /
`free_call.string_length`, which are two of the arms rerouted here, and its
RED (a byte-offset implementation returning U+00A9 at index 1 of "a" U+00E9
"b") is unchanged and still authorable.

* Regen: restore rt_string_ops to the emitter's flat left-fold spine

`--required-regen` refused with `generated surface drift:
v1_compiler_runtime_rust.rs`, and it was right. When the carrier landed I
edited `char_at`'s doc comment in `runtime_rust.dag` and mirrored the four new
literals into the stage0 transliteration BY HAND, splicing them as a
right-branching `concat(concat(concat(L, a), b), c)` subtree hanging off one
element of the chain. The emitter builds one flat left-fold spine over the
whole literal sequence. Both expressions evaluate to the same string -- which
is exactly why nothing else caught it -- but the mirror is compared BYTE-WISE
against a fresh emit, so the shape is the artifact.

Re-emitted `rt_string_ops` as a strict left fold over its 56 literals in order.

Verified against the emitter rather than against my reading of it: a remote
`--required-regen` was run with the pre-fix tree and its candidate artifact
diffed against the committed mirror. The candidate differs on exactly one line,
`rt_string_ops`'s body, and the line this commit installs is byte-identical to
the candidate's -- same 4845 bytes, compared programmatically, not eyeballed.
No other file and no other line drifts, so the carrier's own `rt_string_carrier`
and the rewired `rust_runtime_source` were already in the emitted shape.

The lesson is the repository's own: a generated mirror is not hand-editable
even when the hand edit is semantically correct. What made this recoverable is
that the gate compares bytes and refuses, so the wrong shape could not merge
quietly.

* Record the v1 admission for the string carrier, against the class that refuses it

review 56017 (REQUEST_CHANGES) found that nothing in this PR classifies a
hand-written v1 interpreter change against the seed's admission rules. That gap
was real and this commit closes it.

The receipt lands in `gunbc.v1_maintenance_standing`, which is the authority
that governs v1 changes -- a purpose test, four recorded admission shapes, and
five refused classes that DOMINATE every admission. It does not land in the
form the review named. That form ("a deleted scaffold path, census shrink, or
explicit lane/ROADMAP-row deferral") appears nowhere in DESIGN.md: `hand-Rust`
0 occurrences, `census shrink` 0, `ROADMAP-row` 0. The obligation was real and
the cited authority was not, so writing the receipt in the invented vocabulary
would have been the authority-substitution failure DESIGN's failure-mode list
names -- a fact filed in a carrier that does not govern the operation.

What the row says, and the part that matters is not the admission:

- PURPOSE TEST: satisfied. The interpreter is the engine the v2 self-host
  program runs on, so its per-call string cost is that program's cost. The
  defect is a cost SHAPE (an ASCII walk was O(n^2) because each index re-tested
  a prefix), and DESIGN section 6 states a proven cost-shape defect is always
  fixed regardless of realized n. Recorded instance:
  BehaviorPreservingRedundancyRemoval, with preservation proven by the
  differential tests and the perturbation RED already on the PR.

- PUBLICSURFACEGROWTH, one of the five refused classes, LITERALLY FIRES: the
  emitted seed's exported declarations gain `pub struct RcStr`, and the rung
  note defines that class as a diff over exactly that surface. The row says so
  in those words rather than routing around it, because an admission that omits
  the dominating class is not a classification. The argument for admitting
  anyway is that the class exists to stop the seed ACCUMULATING CAPABILITY and
  none is accumulated -- `RcStr::char_at` returns the same value at every index
  as the `v1_rt::char_at` it shadows, `Value::Str` already exported a string
  payload, and nothing is expressible after this change that was not before, so
  the exported type is the mechanism of a REMOVAL. The counter-argument is
  recorded beside it: `pub struct` in the seed is exactly what the class names,
  and the capability-versus-mechanism distinction is authored, not derived.

- WHO CAN OVERTURN IT: a reviewer or the operator. The carrier already declares
  itself mitigatable -- nothing mechanically refuses a v1 change in a refused
  class -- so this is a judgment on the record, not a wall.

Receipt that the row is well-formed: `gunbc run --entry
dag/gunbc/v1_maintenance_standing.dag` reaches evaluation and stops at
`NoSuchFunction { name: "main" }`, the expected result for a data-only module.
No parse or type diagnostic.

---------

Co-authored-by: Brian Searls <briansearls1@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant