Recount the consumer calls against this release
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m52s
libakstdlib CI Build / sanitizers (push) Successful in 2m59s
libakstdlib CI Build / coverage (push) Successful in 2m45s
libakstdlib CI Build / mutation_test (push) Successful in 12m0s

akbasic's src/ ported onto 0.2.0 calls this library 301 times and raw libc
13 -- 4.1% bypassed, against 86.4% on the same tree before the port and
92.2% at the first count. The port builds clean at -Wall -Wextra, passes
112/112 of akbasic's ctest suite and is ASan+UBSan-clean.

The method was never written down and the figure was not reproducible.
scripts/consumer_calls.py is that method, and reproducing it turned up two
corrections: the old 116 was 117 by its own table's arithmetic, and 119 by
a complete count -- the table had no row for strncmp or memmove.

Nothing was blocked by a missing wrapper. 272 of 285 sites converted; the
13 that did not are blocked by wrapper shape, and are filed as #32-#38.

Say plainly what the number does not cover: akbasic makes 0 calls into
list, tree, hash map and string buffer combined, so the recount is
evidence about the string, memory and format surface and about nothing
else.

Refs #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:53:19 -04:00
parent 2b79aca103
commit cff2a64575
3 changed files with 397 additions and 14 deletions

126
TODO.md
View File

@@ -22,6 +22,7 @@ it has been through grooming.
| Function coverage | 100% (154/154) |
| Doxygen | 100% of 154, gated — `cmake --build build --target docs` fails on an undocumented function, parameter or return |
| Mutation score | 72.3% (188/260 sampled from 1701), gated at 65 |
| Consumer adoption | akbasic ported onto 0.2.0 calls this library 301 times and raw libc 13 — **4.1% bypassed**, from 92.2% at first count. Ungated, and one consumer only |
The six confirmed defects that used to head this file are fixed and
`AKSL_KNOWN_FAILING_TESTS` is empty. What they were, and what changed as a result,
@@ -102,15 +103,16 @@ are fixed: the right child's `depth + 1` in the depth-first walk, and
## Evidence from the first full consumer
`akbasic` (`source.starfort.tech/andrew/akbasic`) is a ~6,300-line C interpreter
built on this library and `libakerror`. It was the first consumer to exercise the
whole surface rather than a corner of it, **and what it could not use is what
prioritised everything that has been built since.**
`akbasic` (`source.starfort.tech/andrew/akbasic`) is a C interpreter built on this
library and `libakerror` — ~6,300 lines of `src/` when it was first measured,
20,169 now. It was the first consumer to exercise the whole surface rather than a
corner of it, **and what it could not use is what prioritised everything that has
been built since.**
**The number that started it.** Across `src/`, akbasic made **10 calls into this
library and 116 to raw libc** — a library whose value proposition is "turn silent
libc failures into error contexts", bypassed 92% of the time by the consumer most
committed to it.
library and 119 to raw libc** — a library whose value proposition is "turn silent
libc failures into error contexts", bypassed **92%** of the time by the consumer
most committed to it.
| Raw libc it had to use | Count | Now available as |
|---|---|---|
@@ -121,8 +123,17 @@ committed to it.
| `strncpy` | 15 | `aksl_strncpy` |
| `strtoll` / `strtod` | 2 | `aksl_strtoll` / `aksl_strtod` |
| `fgets` | 2 | `aksl_fgets` |
| `strncmp` | 1 | `aksl_strncmp` |
| `memmove` | 1 | `aksl_memmove` |
| `strstr` | 1 | `aksl_strstr` |
Two corrections to that figure, both found by rebuilding it. It used to read 116;
the table it sat above summed to 117 and had no row for `strncmp` or `memmove`.
119 is what `scripts/consumer_calls.py` returns against akbasic `4e188b2`, and it
is the number everything below compares to. **The method is now a script rather
than a paragraph, because recovering it afterwards cost more than writing it down
would have.**
**All four things the port had to write for itself now exist here.**
1. **A strict `strtoll`/`strtod` wrapper** (`akbasic/src/convert.c`, ~60 lines). The
@@ -144,8 +155,99 @@ committed to it.
the sign-extended djb2 reads bytes unsigned; and the missing `va_end` — which
akbasic's stdio text sink ran on every line of program output — is fixed.
**Still true, and still shaping the wishlist.** akbasic uses no allocator, no lists
and no trees, drawing everything from fixed pools by design. **A consumer that does
allocate would weight the `open`/`read`/`write` work far higher than this one
does**, so one consumer's count is evidence, not a plan. Re-counting against this
release is #26.
### The recount, against this release
akbasic's `src/` was ported onto 0.2.0 and counted again (#26). The port builds
clean at `-Wall -Wextra`, passes **112/112** of akbasic's ctest suite, and is
ASan+UBSan-clean.
| | libakstdlib | raw libc | bypassed |
|---|---|---|---|
| Baseline — akbasic `4e188b2`, 5,679 lines of `src/` | 10 | 119 | **92.2%** |
| Before the port — akbasic `330d731`, 20,169 lines | 45 | 285 | **86.4%** |
| **After the port — same tree** | **301** | **13** | **4.1%** |
**Read the third row against the second, not the first.** The tree tripled between
the baseline and the port, so 10/119 and 45/285 are counts of two different
programs; only 86.4% → 4.1% is a like-for-like measurement. The 45 in the middle
row is worth its own note — akbasic had already adopted `aksl_f*` across
`runtime_disk.c` on its own, without anybody counting.
**Nothing was blocked by a missing wrapper.** Every libc call akbasic makes had an
`aksl_*` counterpart. 272 of the 285 sites converted; the 13 that did not are
blocked by wrapper *shape*, and they are the useful output:
| Why it could not be used | Sites | Where |
|---|---|---|
| No error channel to route into — the enclosing function returns `bool` or `void`, or is a `bsearch` comparator whose signature libc fixes | 8 | `structtype.c` `word_is`, `environment.c` `akbasic_environment_is_waiting_for` (public API), `scanner.c` `is_at_end` and `peek_next`, `verbs.c` `verb_compare`, `format.c` `overflow`, `sink_akgl.c` `scroll` (×2) |
| Truncation is the answer, not the error | 4 | `format.c`, `structtype.c`, `runtime_struct.c`, `renumber.c` |
| Short-circuit is memory-safety-load-bearing and the compare cannot be hoisted past the NULL arm guarding it | 1 | `runtime_trap.c` |
The truncation four are worth spelling out, because they are a contract decision
rather than an accident. `PRINT USING "###"; 1E300` prints `***` today: the render
truncates, the truncated text has no `.`, and the formatter takes its overflow
path on exactly that. Through `aksl_snprintf` it raises `AKERR_OUTOFBOUNDS` out of
the interpreter instead. Two more are truncation-tolerant renderers that print
what fits and stop, and the fourth uses `snprintf`'s return to raise akbasic's
own `AKBASIC_ERR_BOUNDS` with its own message.
### What the recount found, and where it went
Every one of the 13 blocked sites came back to wrapper *shape* rather than a
missing wrapper, and the same seven shapes recurred across ten independent
conversion passes. They are filed, not listed here:
| Finding | Filed as |
|---|---|
| `aksl_snprintf`'s `count` out-param is required, so ~20 sites carry an `int written` that is written and never read. Raised by all ten passes. `-Wall -Wextra` cannot see it — `&written` is a use | #32 |
| No equality comparison. All 43 comparison sites flatten the three-way `int` to `== 0`; not one wants an ordering, and five now need a sentinel whose *initial value is load-bearing* | #33 |
| No truncating format and no length query, which is the whole of the truncation-four above | #34 |
| `aksl_hashmap_*` carries one payload, which is the only reason `akbasic/src/symtab.c` still exists | #35 |
| `aksl_fgets` signals end of input by raising, so a read loop cannot be a condition | #36 |
| A caller cannot add its own context to a wrapper's error, so it raises and discards instead — eight lines where there were two | #37 |
| No form a `bool` predicate or a `void` function can call, which is 8 of the 13 blocked sites. Carries the `ctype.h` question and the infallible-`memset` question with it | #38 |
`#14` already covered the `bsearch` comparator, and the port confirmed it from the
consumer side.
**The one thing the wrappers did better than the libc they replaced** is worth
recording next to the complaints: `aksl_fgets`'s `len_out` **deleted** two `strlen`
calls rather than converting them, and is more correct than what it replaced for a
line containing an embedded NUL. It is the only one of 272 conversions that
produced less code than it started with.
### Still true, and still the reason one count is not a plan
akbasic uses no allocator, no lists and no trees, drawing everything from fixed
pools by design, and porting it did not change that. Of the 301 calls it now
makes:
| Area | Calls | |
|---|---|---|
| Strings | 122 | 40.5% |
| Memory | 69 | 22.9% |
| Formatted output | 59 | 19.6% |
| Streams | 38 | 12.6% |
| String → number | 12 | 4.0% |
| Hashing | 1 | 0.3% |
| **Collections** | **0** | **0%** |
**Four fifths of the evidence is strings, memory and formatting.** The collections
work — list, tree, hash map, string buffer, `src/collections.c` and the largest
single body of code in this library — has **not one consumer call site**, and the
single hashing call next to it is `aksl_strhash_djb2` feeding a hash table akbasic
wrote for itself. **A consumer that does allocate would weight the
`open`/`read`/`write` work far higher than this one does**, so this remains
evidence and not a plan.
**The number to distrust is not the 4.1%; it is the 0%.** A recount that moves
92% to 4% on one consumer says the string, memory and format wrappers fit the
consumer that asked for them. It says nothing at all about the half of the library
that consumer never calls, and it cannot, however many times it is run. What would
say something is a second consumer with different shape — one that allocates.
`akbasic/src/symtab.c` is the sharpest instance. It is the hand-rolled fixed-capacity
string-keyed hash table `aksl_hashmap_*` was generalised from, it survived the port
untouched, and the reason turned out to be one field rather than a design
disagreement — everything else about the two already lines up. #35 has it, and it
is the first collections work with a consumer actually waiting for it.