ADR-008: Tethered Slices in User Structs — the Tether-State Lattice

Status: Superseded by ADR-283 — its D1, D2 and D5 are D1-D3 there, D6’s inspection half is D4, D7-D8 are D5-D6, its two D9s are D7 and D8; D3, D4’s representation and D6’s assertion are not carried (displaced by ADR-209 and ADR-201) Refines: ADR-005 D4 — the rule was stated over storage, and the event that matters is escape Related: ADR-005 D3 (the ledger) and D8 (determinism), ADR-007 D8 (interner vs. tether), ADR-006 (Cleanup, whose timing D8 changes)

1. Context

ADR-005 D4 settled what happens to a slice a parser yields and something stores. It did not settle the user’s own struct:

pub struct Reading { name: &str, temp: i32 }          // built ~10^9 times
pub struct Summary { stations: HashMap[&str, Stats] } // ~10^4 entries

The rule was over the wrong event. Part I 6.6 said a slice “stored in a struct” is tethered. Taken literally, every Reading carries a Shared handle on the mapped file — and at user_parallelism = yes Shared is Arc, so a billion-row run performs a billion atomic increment/decrement pairs on one refcount word shared by every worker: a single contended cache line, lock xadd per row. The worst access pattern a parallel loop can have, and the rule as written mandated it.

Yet nothing in that program escapes. The mapping outlives the totals, so the correct lowering for both the field and the map key is a plain &'buf str at zero cost. Storage is not the event. Escape is.

That leaves a second question: whether Nikaia should distinguish views (&str) from owned values (String) at all, given that there are no lifetime annotations. Four designs were available — views plus escape analysis (1); one String type with automatic promotion on escape (2a); one type with a hard error on escape (2b); explicit region binding (3).

2. Decisions

D1 — View types stay. &str in a field is a view marker, not a lifetime.

Two syntactic categories: owned (String, List[T], Bytes) and views (&str, &[u8], &T). A struct with a view field declares that it may point into someone else’s buffer. That single sigil is the whole annotation burden — no region name, no parameter, no propagation into the struct’s type. ADR-005 D1 stays absolute.

With one type instead, the tether state becomes a hidden type parameter inferred from use, and that is worse in three ways. The declaration stops carrying what the diagnostic needs, so every cost is explained at a site remote from it — the action at a distance ADR-005 §3 rejected in-source annotations to avoid. The struct’s representation becomes an emergent whole-program property, so one added call site in a distant module changes Reading’s layout, or under 2b turns a compiling program into a rejected one, with no edit anywhere near. And it breaks every existing example — not decisive, but the 0.0.7 authors reached for &str without being told to.

D2 — The tether-state lattice, solved per construction site, not per type.

Every view has one of three states, and the user writes none of them.

State Representation Cost Chosen when
Borrowed native &'r str (ptr, len) zero every use provably lies inside the buffer’s live range
Tethered buffer handle + (offset, len) one Shared handle per container the value escapes that range and the buffer is Shared-capable
Owned String one allocation + copy only where the source says .to_owned()

Borrowed ⊑ Tethered ⊑ Owned, and inference computes the least state that makes the program valid:

Transient = borrow. Escaping = tether. Copying = yours to ask for.

“Escapes” means outliving the buffer’s owning scope: returned past it, captured by a @detached lambda, written into a longer-lived container or a global. A struct field is not an escape by itself, which is what makes the 1BRC inner loop free.

D3 — One state per struct instance: at most three layouts per struct type.

All view fields of an instance share one state, so a struct with k view fields has 3 layouts, not 3^k, and monomorphisation is bounded by the lattice height.

The cost is a struct that would want one borrowed and one tethered field; it is lifted to the join. That is a representation choice and never an allocation, so it cannot silently cost a copy.

D4 — The refcount lives on the container, not on the element.

When a value tethers, the Shared handle is taken by the owner of the escape — the struct, collection or task that outlives the buffer — not by each slice inside it.

This is what makes tethering affordable at 10^9 rows. A HashMap[&str, Stats] that outlives its buffer holds a small buffer table, one handle per distinct source buffer, and its keys stay (buffer_index, offset, len). Where the compiler proves a single buffer — the common case, including chunks that are all subranges of one map — the index is elided and a key is 8 bytes. Inserting a million keys performs zero refcount traffic; merging two containers merges two tables.

Handle traffic is therefore O(containers × buffers), never O(elements), and the failure mode in §1 cannot occur by construction. It also recovers what explicit regions offer — a collection bound to a buffer’s life — by inference, with no region name.

Eq/Hash on views are content-based, never (buffer, range) identity. Two chunks yield different offsets for Hamburg, and a map keyed by identity would silently split stations across workers.

D5 — Promotion to Owned is never automatic.

ADR-005 §3 already bans compiler-inserted clones; here is the performance argument for the struct case. An implicit .to_string() on escape has unbounded invisible cost that scales with the data — a refactor three modules away turns 10^9 zero-copy slices into 10^9 mallocs — and it can inject allocation into a sync body the user believes is allocation-free, which is the opposite of what sync is for. A tether’s cost is bounded by D4 and independent of the row count.

Where neither Borrowed nor Tethered applies — the buffer is not Shared-capable (a stack array, an FFI pointer, a slice the caller lent us) — it is a hard error naming .to_owned() and the two other ways out. That is 2b’s error kept for the case where it is the truth, rather than made the default rule.

D6 — @borrowed is the opt-in “this never tethers” assertion.

@borrowed
pub struct Reading { name: &str, temp: i32 }

Predictability is 2b’s one genuine advantage: a hot path can never quietly change representation. @borrowed takes that without the default cost — an escape that would promote is a compile error naming the escape site, the buffer and the scope that outlives it. It changes no semantics, only forbids a state transition, so it can be added to a hot struct after profiling and removed again without touching anything else.

The inverse tool is inspection, not assertion: because the state lands in the ledger (D7), nikaia explain --tethers prints the solved state of every view, and a change shows up as a ledger diff in review.

D7 — Tether state is part of the ledger, and inherits its determinism.

Recorded per function: the state of every view in its signature. Per struct: the solved state and the buffer-table shape.

Determinism is free. The lattice is a 3-element chain and the constraints are monotone, so this is the same monotone system as the region solver, with the same unique least fixpoint. ADR-005 D8 applies unchanged — no new determinism surface. Switch neutrality holds: the state is identical at every setting of either switch; only Shared’s expansion differs.

Barriers, not blowup. Crossing a dyn boundary or a published non-generic API is a state barrier: the value widens to Tethered — the widest representation, never to Owned. So separate compilation and dynamic dispatch need one ABI rather than one per state, and the trait-method join stays sound without ever costing an allocation.

D8 — Retention is a lint, not a silent leak.

A tether keeps the entire buffer alive: a 12-byte station name out of a 13 GB map pins 13 GB. This is bytes::Bytes’ known space-leak shape. The compiler warns where a small extract escapes a large buffer at a scope boundary, with .to_owned() as the fix-it. An accepted, named cost rather than a surprise.

It also moves a cleanup: a tethered container keeps the mapped file’s handle alive, so ADR-006’s Cleanup runs when the last tether dies, not at the end of the mapping scope. The diagnostic must say so where the two orders differ.

D9 — Explicit region binding is rejected as the core mechanism.

let mut stats = HashMap::new() in file_scope is honest and predictable, but the region name is a lifetime with a friendlier spelling: viral (every function touching stats must name it), it leaks buffer provenance through abstraction boundaries — the caller of a grammar would have to know which scope the parser’s buffer lives in — and it reopens the door D1 nailed shut. D4 delivers the same guarantee by inference.

Kept on the shelf in one narrower form: an explicit arena for owned data as a future allocator feature, which is an allocation strategy and not a lifetime construct.

3. Worked check: 1BRC under these rules

Value Escapes? State Cost
NAME slice inside measurement no Borrowed zero
Reading.name (≈10^9 constructions) no — dies inside the fold Borrowed zero, no atomics
Summary.stations key (≈10^4) no — the totals die before the map Borrowed zero
a chunk from split_aligned no Borrowed one handle per chunk, 8–128 total
that Summary returned past main’s scope yes Tethered one handle for the map, keys 8 bytes

Nothing is allocated per row and no refcount traffic occurs in the parallel section. Under option 2a the last row would have been a billion allocations, chosen silently.

4. Rejected alternatives

D9 — A result that borrows from nothing is 'static.

fn name() -> &str { "Ada" }

emitted fn name() -> &str, and rustc refused it: “missing lifetime specifier — this function’s return type contains a borrowed value, but there is no value for it to be borrowed from”, about a file nobody wrote (Part III, C.1).

A view’s lifetime comes from the input, and Rust’s elision takes it from a reference among the arguments. A function with no reference among them — and no receiver — has nothing to take it from, and 'static is the only lifetime that can be written there: a view handed back by a function that borrowed nothing can only point at something outliving the program.

So this is a derivation rather than a choice, and it is safe in both directions. It never refuses a right program: rustc still checks the body, so a body that cannot honour the 'static is refused about the body — measured, fn first(xs: Vec[String]) -> &str comes back as “cannot return value referencing function parameter xs“, on the Nikaia line, which is the message a reader wants. And it never accepts a wrong one, because nothing about the signature is taken on trust.

A receiver counts, and so does any parameter that is a view or holds one: where either is present the result keeps the elision it had, since writing 'static there would demand more of the caller than the body needs.

5. Consequences

Part I 6.6 is restated over escape rather than storage and gains the lattice; Part II 10.6 inherits it; @borrowed and the retention lint enter Part III.

The region solver gains a second, tiny lattice, and the ledger schema gains a state column and a buffer-table shape per struct.

Open: the buffer table’s index width and its growth policy for containers fed by many distinct buffers — a streaming parser over thousands of files. The single-buffer case is fully determined.

6. What is built

All three, since ADR-209 (0.0.185). Tethered is a keep — an append-only store the buffer moves into — owned by the nearest frame that outlives the views, by a handle a task carries, or by each view of a container that drops entries; the views stay plain references. D3’s three layouts and D4’s positions were not needed: ADR-209 §2 says why. What follows is what stood before, kept for the record.

Two of the three states, and the middle one is a refusal.

D9 is built, and for a result that carries a view as well as one that is one. It asked the question of the written type, so -> Vec[Entry] — where Entry holds a &str — went on eliding and the answer was Vec<Entry<'_>>: missing lifetime specifier about a file nobody wrote, which is the defect D9 exists to close, one type in. The declaration is what tells the two apart and the emitter has it. Only in a signature that declares no lifetime of its own, which is D9’s own case: inside an impl that declares 'a the result is the subject’s, and 'static there is a promise the impl cannot keep — examples/1brc.nika’s anonymous constructor is what said so.

The analysis, built before the representation

D2 is solved and D7’s first half is written: every view in a signature carries its state in the ledger, under views, and nothing reads it. That order is the point rather than a half-measure — an analysis whose answers nothing depends on can be held against the whole corpus and read, and being wrong costs a wrong line in a file rather than a wrong program.

Owned is never the analysis’s word (D5). A .to_owned() hands back a String, which is not a view at all, so no view position is ever Owned and the lattice this solves over is the two states below it. The top exists for the program to reach, not for the compiler to assign.

The question, once the caller’s buffers are out of the picture. A parameter is lent for the call, so a view in one borrows; a result borrows where a receiver or a parameter carries a view, because that buffer is the caller’s. Where nothing does, the view points either at something that outlives the program — D9’s fn name() -> &str { "Ada" } — or at a buffer this body made, and the second is the escape. Does this body own a buffer is therefore the whole of it, and it is read off the ledger: a let whose initialiser hands back a String, a Bytes, a Mapped or a run of u8, plus to_owned and to_string by name.

It errs towards Tethered, which is D7’s own polarity for the case that record names: a barrier widens to Tethered, the widest representation, never to Owned. An unresolved call is a buffer this walk could not rule out. The one shape it does not decide is a buffer built element by element into a list whose element type the ledger does not name; no program in the tree writes one.

What it found: the whole corpus is the free case. Every view in every signature of examples/ and benches/ solves to Borrowed — §3’s worked check, read off the analysis rather than asserted. A test holds it as a ceiling, so a program that starts needing the state that is not built says so.

D7’s second half is not written. Per struct: the solved state and the buffer-table shape is a fact about a representation, and the buffer table is named in the same breath; a type already records which of its fields hold a view. It belongs with the layouts.

D6’s inspection half is built: --tethers prints what was solved, beside --trust, --overlaps and --sharing. The assertion half is not, for the reason above — there is no transition to forbid.

#196 carries what Tethered would take, in seven parts, and says which of them decides whether the rest is worth starting.

This site is open source. Improve this page.