From 227ba54667105254a7c2bea2e47244062fdb7a1f Mon Sep 17 00:00:00 2001 From: "Claude (adr007-slug)" Date: Fri, 9 Oct 2026 12:43:28 -0700 Subject: [PATCH 1/4] feat(core): slugify, id syntax, natural order and display suffixes per ADR 007 amendment 1 (improvise-cq1q.14) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-core/src/model/id.rs | 340 ++++++++++++++++++++++---- 1 file changed, 297 insertions(+), 43 deletions(-) diff --git a/crates/improvise-core/src/model/id.rs b/crates/improvise-core/src/model/id.rs index 7448a647..5340cc09 100644 --- a/crates/improvise-core/src/model/id.rs +++ b/crates/improvise-core/src/model/id.rs @@ -1,6 +1,7 @@ -//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4). -//! Reasoning: `context/code/core.md` §20. -use std::collections::HashSet; +//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4 +//! and its amendment). Reasoning: `context/code/core.md` §20. +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; /// What a generated id names; spells the `` of an empty slug's `-`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -9,6 +10,7 @@ pub enum IdKind { Item, Formula, View, + Group, } impl IdKind { @@ -18,38 +20,111 @@ impl IdKind { IdKind::Item => "item", IdKind::Formula => "formula", IdKind::View => "view", + IdKind::Group => "group", } } } -/// The only ids that may begin with `_` (answer 23). -pub const VIRTUAL_IDS: [&str; 3] = ["_index", "_dim", "_measure"]; +/// The only ids that may begin with `_` (amendment 1, clause 1). +pub const VIRTUAL_IDS: [&str; 5] = ["_index", "_dim", "_measure", "_records", "_drill"]; -/// A display name's slug; empty when nothing survives (answers 16, 18, 21, 23, 26). +/// A display name's slug; empty when nothing survives (amendment 1, clause 4). pub fn slugify(display: &str) -> String { let folded = unicase::UniCase::new(display).to_folded_case(); - let kept: String = underscore_space_runs(&folded) + let mut slug = String::new(); + for c in folded .chars() - .filter(|&c| in_id_alphabet(c)) - .collect(); - kept.trim_start_matches('_').to_string() + .map(|c| if in_slug_alphabet(c) { c } else { '_' }) + { + if !(c == '_' && slug.ends_with('_')) { + slug.push(c); + } + } + let trimmed = slug.trim_matches(|c| c == '_' || c == '-'); + if trimmed.starts_with(|c: char| c.is_ascii_digit()) { + format!("n{trimmed}") + } else { + trimmed.to_string() + } } -fn underscore_space_runs(s: &str) -> String { - let (out, _) = s - .chars() - .fold((String::new(), false), |(mut out, prev), c| { - let space = c == ' '; - if !(space && prev) { - out.push(if space { '_' } else { c }); - } - (out, space) - }); - out +fn in_slug_alphabet(c: char) -> bool { + matches!(c, 'a'..='z' | '0'..='9' | '-') } -fn in_id_alphabet(c: char) -> bool { - matches!(c, 'a'..='z' | '0'..='9' | '-' | '_') +/// Whether `id` has the syntax of a generated id: a letter, then `a`-`z`, `0`-`9`, +/// `-` and single underscores, never ending in `_` or `-`. A virtual id fails it. +pub fn is_id_syntax(id: &str) -> bool { + let b = id.as_bytes(); + let edges = matches!(b.first(), Some(b'a'..=b'z')) && !matches!(b.last(), Some(b'_' | b'-')); + edges && b.windows(2).all(|w| w != b"__") && id.chars().all(|c| in_slug_alphabet(c) || c == '_') +} + +/// Ids in natural order: digit runs by value, then fewer leading zeros first. +pub fn natural_cmp(a: &str, b: &str) -> Ordering { + let (mut ra, mut rb) = (runs(a), runs(b)); + loop { + match (ra.next(), rb.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(x), Some(y)) => match run_cmp(x, y) { + Ordering::Equal => {} + other => return other, + }, + } + } +} + +fn runs(s: &str) -> impl Iterator { + let mut rest = s; + std::iter::from_fn(move || { + let digit = rest.chars().next()?.is_ascii_digit(); + let end = rest + .find(|c: char| c.is_ascii_digit() != digit) + .unwrap_or(rest.len()); + let (run, tail) = rest.split_at(end); + rest = tail; + Some(run) + }) +} + +fn run_cmp(x: &str, y: &str) -> Ordering { + let digits = |r: &str| r.starts_with(|c: char| c.is_ascii_digit()); + if digits(x) && digits(y) { + let (vx, vy) = (x.trim_start_matches('0'), y.trim_start_matches('0')); + vx.len() + .cmp(&vy.len()) + .then_with(|| vx.cmp(vy)) + .then_with(|| x.len().cmp(&y.len())) + } else { + x.cmp(y) + } +} + +/// What each `(id, display)` pair is shown as: equal displays get ` #2`, ` #3`, … +/// in natural id order, skipping numbers a literal display already holds. +pub fn display_suffixes(items: &[(&str, &str)]) -> Vec { + let literal: HashSet<&str> = items.iter().map(|&(_, d)| d).collect(); + let mut groups: HashMap<&str, Vec> = HashMap::new(); + for (i, &(_, d)) in items.iter().enumerate() { + groups.entry(d).or_default().push(i); + } + let mut shown: Vec = items.iter().map(|&(_, d)| d.to_string()).collect(); + for (display, mut at) in groups { + at.sort_by(|&i, &j| natural_cmp(items[i].0, items[j].0)); + let mut n = 2; + for &i in at.iter().skip(1) { + shown[i] = loop { + let candidate = format!("{display} #{n}"); + n += 1; + if !literal.contains(candidate.as_str()) { + break candidate; + } + }; + } + } + shown } /// Why a hand-written id cannot enter the document. @@ -81,12 +156,13 @@ impl IdAllocator { Self::default() } - /// Admit a hand-written id verbatim: it is canonical and never renamed (answer 29). + /// Admit a hand-written id verbatim: it is canonical and never renamed. pub fn claim(&mut self, id: &str) -> Result<(), ClaimError> { - let refusal = if id.is_empty() || !id.chars().all(in_id_alphabet) { - Some(ClaimError::Malformed(id.to_string())) - } else if id.starts_with('_') && !VIRTUAL_IDS.contains(&id) { + let virtual_id = VIRTUAL_IDS.contains(&id); + let refusal = if id.starts_with('_') && !virtual_id { Some(ClaimError::Reserved(id.to_string())) + } else if !virtual_id && !is_id_syntax(id) { + Some(ClaimError::Malformed(id.to_string())) } else if self.seen.contains(id) { Some(ClaimError::Duplicate(id.to_string())) } else { @@ -132,16 +208,26 @@ mod tests { use proptest::prelude::*; #[test] - fn slugify_follows_the_adrs_examples() { + fn slugify_follows_the_amendments_table() { let cases = [ - ("East", "east"), ("Time Period", "time_period"), - ("Coastal_East", "coastal_east"), - ("Q1-2024", "q1-2024"), - ("Region(code)", "regioncode"), - ("Café", "caf"), - ("_Draft", "draft"), + ("Time\tPeriod", "time_period"), + ("Time \u{200B} Period", "time_period"), + ("a_ b", "a_b"), + ("a__b", "a_b"), + ("Q1 - 2024", "q1_-_2024"), + ("East ", "east"), + ("East_", "east"), + ("-East-", "east"), + ("Region(code)", "region_code"), + ("Crème Brûlée", "cr_me_br_l_e"), + ("2024", "n2024"), + ("_2024", "n2024"), + ("2024-Q1", "n2024-q1"), ("Ü", ""), + ("-", ""), + ("%", ""), + ("", ""), ]; for (display, slug) in cases { assert_eq!(slugify(display), slug, "slugify({display:?})"); @@ -156,15 +242,21 @@ mod tests { } #[test] - fn slugify_collapses_a_run_of_spaces_to_one_underscore() { - assert_eq!(slugify("Time Period"), "time_period"); - assert_eq!(slugify("East "), "east_"); + fn slugify_trims_hyphens_and_underscores_until_neither_is_at_an_end() { + assert_eq!(slugify("_-_East-_-"), "east"); + assert_eq!(slugify("-_-"), ""); } #[test] - fn slugify_keeps_an_underscore_the_display_already_had() { - assert_eq!(slugify("a_ b"), "a__b"); - assert_eq!(slugify("a__b"), "a__b"); + fn slugify_trims_before_it_prepends_the_n() { + assert_eq!(slugify("-_7up"), "n7up"); + assert_eq!(slugify("n-7"), "n-7"); + } + + #[test] + fn slugify_keeps_a_hyphen_between_underscores() { + assert_eq!(slugify("a - b"), "a_-_b"); + assert_eq!(slugify("a_-b"), "a_-b"); } #[test] @@ -173,6 +265,8 @@ mod tests { assert_eq!(ids.generate(IdKind::Category, "Ü"), "category-1"); assert_eq!(ids.generate(IdKind::Item, "Ö"), "item-2"); assert_eq!(ids.generate(IdKind::View, ""), "view-3"); + assert_eq!(ids.generate(IdKind::Group, "-"), "group-4"); + assert_eq!(ids.generate(IdKind::Formula, "%"), "formula-5"); } #[test] @@ -197,6 +291,15 @@ mod tests { assert_eq!(ids.generate(IdKind::Item, "EAST"), "east-3"); } + #[test] + fn a_hyphenated_slug_and_a_counter_suffix_never_collide() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.generate(IdKind::Item, "East"), "east"); + assert_eq!(ids.generate(IdKind::Item, "East-1"), "east-1"); + assert_eq!(ids.generate(IdKind::Item, "east"), "east-2"); + assert_eq!(ids.generate(IdKind::Item, "East-1"), "east-1-3"); + } + #[test] fn a_claimed_id_keeps_its_spelling_and_generation_steps_past_it() { let mut ids = IdAllocator::new(); @@ -224,10 +327,21 @@ mod tests { ); } + #[test] + fn claim_refuses_what_a_generated_id_could_not_be() { + let mut ids = IdAllocator::new(); + for bad in ["-x", "x-", "2024", "a__b", "x_", "-", "a b", "é"] { + assert_eq!(ids.claim(bad), Err(ClaimError::Malformed(bad.into()))); + } + for good in ["east-1", "item-4", "a-7", "q1_-_2024", "a_b", "x"] { + assert_eq!(ids.claim(good), Ok(()), "{good}"); + } + } + #[test] fn claim_refuses_an_id_that_only_begins_like_a_virtual_one() { let mut ids = IdAllocator::new(); - for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures"] { + for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures", "_recordsx"] { assert_eq!( ids.claim(lookalike), Err(ClaimError::Reserved(lookalike.into())) @@ -235,10 +349,20 @@ mod tests { } } + #[test] + fn claim_admits_each_virtual_id_once() { + let mut ids = IdAllocator::new(); + for v in VIRTUAL_IDS { + assert_eq!(ids.claim(v), Ok(())); + assert_eq!(ids.claim(v), Err(ClaimError::Duplicate(v.into()))); + } + } + #[test] fn a_display_with_leading_spaces_never_slugs_into_the_reserved_space() { assert_eq!(slugify(" Draft"), "draft"); assert_eq!(slugify(" _ Draft"), "draft"); + assert_eq!(slugify("_Drill"), "drill"); } #[test] @@ -272,7 +396,138 @@ mod tests { assert_eq!(run(), run()); } + #[test] + fn natural_order_puts_a_counter_suffix_in_numeric_order() { + let mut ids = vec!["profit-10", "profit-2", "profit", "profit-1"]; + ids.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!(ids, ["profit", "profit-1", "profit-2", "profit-10"]); + } + + #[test] + fn natural_order_ties_on_value_go_to_fewer_leading_zeros() { + assert_eq!(natural_cmp("a-7", "a-007"), Ordering::Less); + assert_eq!(natural_cmp("a-007", "a-7"), Ordering::Greater); + assert_eq!(natural_cmp("a-07", "a-007"), Ordering::Less); + assert_eq!(natural_cmp("a-7", "a-7"), Ordering::Equal); + } + + #[test] + fn natural_order_compares_digit_runs_of_any_length_by_value() { + let big = "9".repeat(40); + let bigger = format!("1{}", "0".repeat(40)); + assert_eq!( + natural_cmp(&format!("x{big}"), &format!("x{bigger}")), + Ordering::Less + ); + assert_eq!(natural_cmp("x9", "x10"), Ordering::Less); + assert_eq!(natural_cmp("x000", "x0"), Ordering::Greater); + } + + #[test] + fn natural_order_puts_the_id_that_runs_out_of_runs_first() { + assert_eq!(natural_cmp("a", "a-1"), Ordering::Less); + assert_eq!(natural_cmp("a-1", "a-1-b"), Ordering::Less); + assert_eq!(natural_cmp("a-1b", "a-1"), Ordering::Greater); + } + + #[test] + fn natural_order_compares_non_digit_runs_by_bytes() { + assert_eq!(natural_cmp("b1", "a2"), Ordering::Greater); + assert_eq!(natural_cmp("a-1", "a_1"), Ordering::Less); + } + + #[test] + fn a_suffix_numbers_equal_displays_in_natural_id_order() { + let items = [ + ("profit-10", "Profit"), + ("profit", "Profit"), + ("profit-2", "Profit"), + ("profit-1", "Profit"), + ("cost", "Cost"), + ]; + assert_eq!( + display_suffixes(&items), + [ + "Profit #4", + "Profit", + "Profit #3", + "Profit #2", + "Cost" + ] + ); + } + + #[test] + fn a_suffix_skips_a_number_a_literal_display_already_holds() { + let items = [ + ("profit", "Profit"), + ("profit-1", "Profit"), + ("profit-2", "Profit"), + ("p2", "Profit #2"), + ]; + assert_eq!( + display_suffixes(&items), + ["Profit", "Profit #3", "Profit #4", "Profit #2"] + ); + } + + #[test] + fn a_suffix_is_gapless_when_a_thing_goes_away() { + let three = [("p", "Profit"), ("p-1", "Profit"), ("p-2", "Profit")]; + assert_eq!(display_suffixes(&three), ["Profit", "Profit #2", "Profit #3"]); + let two = [("p", "Profit"), ("p-2", "Profit")]; + assert_eq!(display_suffixes(&two), ["Profit", "Profit #2"]); + } + + #[test] + fn a_unique_display_is_shown_unchanged() { + let items = [("a", "East"), ("b", "West")]; + assert_eq!(display_suffixes(&items), ["East", "West"]); + assert!(display_suffixes(&[]).is_empty()); + } + + fn id_text() -> impl Strategy { + "[a-c0-3_-]{0,7}" + } + proptest! { + #[test] + fn slugify_output_has_the_id_syntax_and_is_a_fixed_point(display in ".{0,12}") { + let slug = slugify(&display); + prop_assert!(slug.is_empty() || is_id_syntax(&slug), "{slug:?}"); + prop_assert_eq!(slugify(&slug), slug); + } + + #[test] + fn id_syntax_agrees_with_its_regular_expression(id in "[a-c0-9_-]{0,8}") { + let spelled = id.starts_with(|c: char| c.is_ascii_lowercase()) + && !id.ends_with(['_', '-']) + && !id.contains("__"); + prop_assert_eq!(is_id_syntax(&id), spelled); + } + + #[test] + fn natural_order_is_a_total_order(a in id_text(), b in id_text(), c in id_text()) { + prop_assert_eq!(natural_cmp(&a, &b), natural_cmp(&b, &a).reverse()); + prop_assert_eq!(natural_cmp(&a, &b) == Ordering::Equal, a == b); + if natural_cmp(&a, &b) != Ordering::Greater && natural_cmp(&b, &c) != Ordering::Greater { + prop_assert!(natural_cmp(&a, &c) != Ordering::Greater); + } + } + + #[test] + fn suffixed_displays_are_distinct_when_the_literals_are_not_already_clashing( + ids in proptest::collection::hash_set("[a-c][0-9]{0,2}", 0..8), + display in "[AB]( #[2-4])?" + ) { + let mut ids: Vec = ids.into_iter().collect(); + ids.sort_by(|a, b| natural_cmp(a, b)); + let items: Vec<(&str, &str)> = ids.iter().map(|i| (i.as_str(), display.as_str())).collect(); + let shown = display_suffixes(&items); + let distinct: HashSet<&String> = shown.iter().collect(); + prop_assert_eq!(distinct.len(), shown.len()); + } + #[test] fn generated_ids_are_distinct_well_formed_and_never_reserved( displays in proptest::collection::vec(".{0,6}", 0..24) @@ -281,8 +536,7 @@ mod tests { let mut out = HashSet::new(); for d in &displays { let id = ids.generate(IdKind::Item, d); - prop_assert!(!id.is_empty() && !id.starts_with('_'), "{id:?}"); - prop_assert!(id.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))); + prop_assert!(is_id_syntax(&id), "{id:?}"); prop_assert!(out.insert(id.clone()), "reissued {id:?}"); } } From 2e1f0f65508f4a18adfcfbf11ad99b3b6f28dcce Mon Sep 17 00:00:00 2001 From: "Claude (adr007-slug)" Date: Fri, 9 Oct 2026 12:49:09 -0700 Subject: [PATCH 2/4] docs(context): core.md section 20 and test evidence for the amended id rules (improvise-cq1q.14) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 89 ++++++++++++++------------ context/test-evidence/core.md | 92 +++++++++++++++++++++++---- crates/improvise-core/src/model/id.rs | 16 ++--- 3 files changed, 136 insertions(+), 61 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index c00deb72..67a004ef 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -1688,16 +1688,16 @@ probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a ## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2) -`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure -functions of the display and the allocator's own history. Nothing calls them -yet: the creation sites move onto them in the re-key step (improvise-cq1q.4). +`slugify`, `IdAllocator`, `natural_cmp` and `display_suffixes` are the first +piece of ADR 007. They are pure functions of their arguments and the +allocator's own history. Nothing calls them yet: the creation sites move onto them in the re-key step (improvise-cq1q.4). Each rule traces to ADR 007's clause 4 (slugify, the counter, hand-written ids) or clause 1 (reserved ids). **Case folding is full Unicode folding, and it comes from `unicase`.** -`str::to_lowercase` is not folding: `ß` lowercases to itself and is then -stripped, so `Straße` would slug to `strae` (clause 4, step 1: full case folding). The same goes for `ſ` -(long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no +`str::to_lowercase` is not folding: `ß` lowercases to itself and then +becomes `_`, so `Straße` would slug to `stra_e` (clause 4, step 1: full case +folding). The same goes for `ſ` (long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no dependencies, so it passes the wasm gate. Its `to_folded_case` applies the C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` → `masse`. Most of what folding produces is stripped anyway: only an ASCII @@ -1705,31 +1705,45 @@ letter survives step 3. Deriving that ASCII-producing subset of the table by hand was rejected. It is a second copy of the Unicode data, and nothing would say when it drifted. -**A "space" is U+0020.** The accepted clause 4 says "replace each run of spaces -with one `_`". A tab or a no-break space is not a space here, so it is -stripped by step 3. Only a run of U+0020 becomes one `_`. An underscore the -display already had is never merged with the one a space run makes. So `a_ b` -slugs to `a__b`: the steps are applied as written, and no extra -underscore-collapsing step exists. A trailing run survives, so `East ` slugs -to `east_`. Only *leading* underscores are removed (step 4). +**Slugify is the amendment's six steps** (ADR 007 amendment 1, clause 4): +casefold; every character outside `a`–`z`, `0`–`9` and `-` becomes `_`; runs of +`_` collapse; leading and trailing `_` and `-` are removed, all of them (the +trim is `trim_matches` over both characters, so `_-_East-_-` is `east`); +`n` is prepended to a slug that starts with a digit; nothing left is the +caller's `-`. The order matters in one place: the trim runs before +the `n`, so `-_7up` is `n7up` and a leading `-` can never be hidden behind the +prefix. Whitespace of every kind, punctuation and every non-ASCII letter +become `_`, so `Region(code)` is `region_code` and `Crème Brûlée` is +`cr_me_br_l_e`. Folding is `unicase`'s, which maps `ß` to `ss`; a character +that folds to a combining mark (`İ` folds to `i` plus U+0307) leaves the `i` +and turns the mark into `_`, which the trim then removes. A hyphen between +underscores survives (`a - b` is `a_-_b`). -**ADR 007's amendment changes these rules, and this code predates it.** The -amendment (`context/adr/007-stable-identities-amendment-1.md`, accepted 2026-10-09) -says in its clause 4: -- every character outside `a`–`z`, `0`–`9` and `-` becomes `_` instead of being - stripped (not only U+0020), so `Region(code)` slugs to `region_code`, while - `Q1-2024` keeps its `-`; -- runs of `_` collapse, so `a_ b` and `a__b` both slug to `a_b`; -- leading and trailing `_` and `-` are removed, so `East ` → `east`, `-East-` → - `east`, and `-` alone takes `-`; -- `n` is prepended to a slug that starts with a digit, so `2024` → `n2024`; -- a hand-written id must obey every syntax rule of a generated one. +**The id syntax has one validator, `is_id_syntax`**: a lower-case ASCII +letter, then `a`–`z`, `0`–`9`, `-` and single underscores, never ending in +`_` or `-`. Slugify's non-empty output always satisfies it and is a fixed point +of itself, and a `-` suffix keeps the shape, so a generated id always +validates (properties in `model::id::tests`). A virtual id fails it by design, +because the leading `_` is the reservation. -This section, `slugify`, and the tests -`slugify_collapses_a_run_of_spaces_to_one_underscore` (its `east_` case) and -`slugify_keeps_an_underscore_the_display_already_had` describe the earlier rule -above. They change in a follow-up the amendment calls for. The amendment's -clause 4 lists every difference. +**Natural order is `natural_cmp`** (clause 4, "Ids are always sorted in +natural order"). An id is split into alternating digit and non-digit runs, and +the runs are compared pairwise: non-digit runs by bytes; digit runs by value +(strip leading zeros, shorter is smaller, then bytes), and a tie on value goes +to the run with fewer leading zeros, immediately and not after the later runs. +The id that runs out of runs first sorts first. No digit run is parsed into an +integer, so a run of any length compares correctly. Where two ids disagree +about the kind of run at a position (only possible for text that is not an +id), the runs compare as bytes. It is a total order and equal only for equal +strings. **Nothing calls it yet**: `CellKey`, `DataStore`, the writer and the GP +dataset still sort with `String::cmp` until the re-key step. + +**Equal displays are suffixed by `display_suffixes`** (clause 2). Given +`(id, display)` pairs it returns what each is shown as, in input order: the +first of a group of equal displays in natural id order keeps the bare display, +the rest get ` #2`, ` #3`, …, and a number is skipped when a literal display +already reads ` #` anywhere in the scope. Displays are equal +byte for byte, not case-folded. It is pure and nothing renders through it yet. **The allocator records every id it admits and releases none.** That is the whole of retirement (clause 4, "within a session a removed id is never handed @@ -1750,17 +1764,14 @@ name happens to be free, because clause 4 spells the result `-`. **`claim` is the only door for a hand-written id.** It checks, in this order: -the alphabet (non-empty, `a`–`z`, `0`–`9`, `-`, `_`), the reserved leading -`_` (only `_index`, `_dim` and `_measure` may carry one, per clause 1, and -the match is exact: `_indexer` and `_measure-1` are Reserved, since "no other -id may start with `_`"), and -duplicates. It accepts `-`, `-x`, `x-`, `a__b` and `2024`, all of which the -amendment refuses (a hand-written id obeys the syntax of a generated one). It never renames. -Refusing is the loader's job, and the loader +the reserved leading `_` (only `_index`, `_dim`, `_measure`, `_records` and +`_drill` may carry one, and the match is exact: `_indexer` and `_measure-1` +are Reserved), the syntax (`is_id_syntax`; `-x`, `x-`, `2024`, `a__b` and `x_` +are Malformed, `east-1`, `item-4`, `a-7` and `q1_-_2024` pass), and +duplicates. A virtual id skips the syntax check, since it fails it by design. +It never renames. Refusing is the loader's job, and the loader must name both lines of a duplicate; `ClaimError` carries only the id, so the -loader keeps the line numbers. The virtual views `_records` and `_drill` are -not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive -with the ADR text fix (improvise-cq1q.1) rather than ahead of it. +loader keeps the line numbers. `IdKind::Group` spells `group-`. ## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3) diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index d4ae4c48..b25328ca 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1914,30 +1914,96 @@ identity on the view. header stays for the sibling quarter that has data. Rival: `prune_axis_items` keying open runs by level alone. This test and the `NESTED` enumeration fail. -## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2) +## `model/id.rs` (ADR 007 clause 4 and amendment 1, improvise-cq1q.2, improvise-cq1q.14) -These tests were written before `slugify` and `IdAllocator` had bodies. With -`todo!()` bodies all eleven failed. Each rival below was then injected into the -finished module, run against `cargo test -p improvise-core --lib model::id`, and +Each rival below was injected into the finished module, run against `cargo test -p improvise-core --lib model::id`, and reverted. The reasoning is `context/code/core.md` §20. ### model::id::tests::slugify_case_folds_rather_than_lowercases Rival: `display.to_lowercase()` in place of `unicase`'s full folding. Only -this test fails: `left: "strae"`, `right: "strasse"`. `ß` lowercases to itself -and step 3 strips it. `slugify_follows_the_adrs_examples` cannot see this rival, +this test fails: `ß` lowercases to itself and becomes `_`, so `stra_e` against +`strasse`. `slugify_follows_the_amendments_table` cannot see this rival, because each of its displays lowercases and folds alike. -### model::id::tests::slugify_collapses_a_run_of_spaces_to_one_underscore +### model::id::tests::slugify_follows_the_amendments_table -Rival: every space becomes `_`, with runs not collapsed. Only this test fails: -`"time___period"`. No other fixture has two spaces in a row. +Every row of the amendment's clause 4 table, plus `-`, `%` and the empty +display. Rivals, each of which fails this test: trimming only leading `_` +(`East_` stays), trimming `_` and not `-` (`-East-`), not collapsing runs of +`_` (`a__b`), not prepending `n` (`2024`), and stripping instead of replacing +(`Region(code)` is `regioncode`). The last also fails +`slugify_keeps_a_hyphen_between_underscores` and +`a_slug_taken_by_another_kind_takes_a_suffix`. -### model::id::tests::slugify_keeps_an_underscore_the_display_already_had +### model::id::tests::slugify_trims_hyphens_and_underscores_until_neither_is_at_an_end -Rival: `.replace("__", "_")` after the space step, which reads the accepted -clause 4 as "no doubled underscores". Only this test fails: `left: "a_b"`. The rule -collapses runs of *spaces*. An underscore that was in the display is kept. +Rival: trim leading `_` only, or `_` only at both ends. `_-_East-_-` keeps a +hyphen, and `-_-` is not empty. A single `trim_matches` over both characters +passes; two sequential single-character trims would not. + +### model::id::tests::slugify_trims_before_it_prepends_the_n + +Rival: no `n` prefix, which fails this test, the table and the property below. +`-_7up` is `n7up` only if the trim runs first; prepending first would leave the +`-_` in front of the `n`. + +### model::id::tests::slugify_output_has_the_id_syntax_and_is_a_fixed_point + +The property over arbitrary displays: a non-empty slug satisfies +`is_id_syntax` and slugifying it returns it. Rivals that fail it (and +`generated_ids_are_distinct_well_formed_and_never_reserved`): no `_` +collapse, no `n` prefix, keeping a trailing `_` or `-`. + +### model::id::tests::id_syntax_agrees_with_its_regular_expression + +`is_id_syntax` against the spelled-out rule over `[a-c0-9_-]{0,8}`. Rivals: +allowing `__`, allowing a leading digit and allowing a trailing `-` each fail +here and in `claim_refuses_what_a_generated_id_could_not_be`. + +### model::id::tests::claim_refuses_what_a_generated_id_could_not_be + +Rival: `claim` keeps the old alphabet-only check. `-x`, `x-`, `2024`, `a__b`, +`x_` and `-` are admitted, and the test fails. It also pins the accepted ids +`east-1`, `item-4`, `a-7` and `q1_-_2024`. + +### model::id::tests::claim_admits_each_virtual_id_once + +Rival: `VIRTUAL_IDS` without `_records` and `_drill`. The test spells the five +ids out instead of iterating the constant, because an iteration over the +constant passes with the constant shortened (the first version did). + +### model::id::tests::natural_order_puts_a_counter_suffix_in_numeric_order + +Rivals: comparing digit runs as strings (`profit-10` before `profit-2`) fails +this test, `natural_order_compares_digit_runs_of_any_length_by_value` and +`a_suffix_numbers_equal_displays_in_natural_id_order`; plain `str::cmp` fails +those and `natural_order_ties_on_value_go_to_fewer_leading_zeros`. + +### model::id::tests::natural_order_ties_on_value_go_to_fewer_leading_zeros + +Rivals: more zeros first, or no tie-break (`a-7` equal to `a-007`). Both fail this test and the any-length test (`x000` after `x0`). + +### model::id::tests::natural_order_puts_the_id_that_runs_out_of_runs_first + +Rival: the longer id first. Only this test fails. + +### model::id::tests::natural_order_is_a_total_order + +Antisymmetry, equality only for equal strings and transitivity over +`[a-c0-3_-]{0,7}`, which includes ids starting with a digit. + +### model::id::tests::a_suffix_numbers_equal_displays_in_natural_id_order + +Rival: ordering the group by `str::cmp` on the ids. `profit-10` is numbered +before `profit-2` and this test fails alone. + +### model::id::tests::a_suffix_skips_a_number_a_literal_display_already_holds + +Rival: numbering without the skip. A literal `Profit #2` and a generated +`Profit #2` are then both shown, and this test fails alone. A rival that starts +numbering at ` #1` also fails it and +`a_suffix_is_gapless_when_a_thing_goes_away`. ### model::id::tests::the_suffix_is_the_next_counter_value_not_the_lowest_free_number diff --git a/crates/improvise-core/src/model/id.rs b/crates/improvise-core/src/model/id.rs index 5340cc09..dfdea0f1 100644 --- a/crates/improvise-core/src/model/id.rs +++ b/crates/improvise-core/src/model/id.rs @@ -239,6 +239,7 @@ mod tests { assert_eq!(slugify("Straße"), "strasse"); assert_eq!(slugify("ſtate"), "state"); assert_eq!(slugify("file"), "file"); + assert_eq!(slugify("İ"), "i"); } #[test] @@ -352,7 +353,7 @@ mod tests { #[test] fn claim_admits_each_virtual_id_once() { let mut ids = IdAllocator::new(); - for v in VIRTUAL_IDS { + for v in ["_index", "_dim", "_measure", "_records", "_drill"] { assert_eq!(ids.claim(v), Ok(())); assert_eq!(ids.claim(v), Err(ClaimError::Duplicate(v.into()))); } @@ -447,13 +448,7 @@ mod tests { ]; assert_eq!( display_suffixes(&items), - [ - "Profit #4", - "Profit", - "Profit #3", - "Profit #2", - "Cost" - ] + ["Profit #4", "Profit", "Profit #3", "Profit #2", "Cost"] ); } @@ -474,7 +469,10 @@ mod tests { #[test] fn a_suffix_is_gapless_when_a_thing_goes_away() { let three = [("p", "Profit"), ("p-1", "Profit"), ("p-2", "Profit")]; - assert_eq!(display_suffixes(&three), ["Profit", "Profit #2", "Profit #3"]); + assert_eq!( + display_suffixes(&three), + ["Profit", "Profit #2", "Profit #3"] + ); let two = [("p", "Profit"), ("p-2", "Profit")]; assert_eq!(display_suffixes(&two), ["Profit", "Profit #2"]); } From b17bcb068bef266c7033a75ea3f10129ccacbe15 Mon Sep 17 00:00:00 2001 From: "Claude (adr007-slug)" Date: Fri, 9 Oct 2026 12:55:14 -0700 Subject: [PATCH 3/4] docs(context): core.md section 20 traces its rules to all three clauses and says folding output becomes _ (improvise-cq1q.14) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index 67a004ef..24f123bb 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -1692,7 +1692,8 @@ probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a piece of ADR 007. They are pure functions of their arguments and the allocator's own history. Nothing calls them yet: the creation sites move onto them in the re-key step (improvise-cq1q.4). Each rule traces to ADR 007's clause 4 (slugify, the counter, hand-written -ids) or clause 1 (reserved ids). +ids, natural order), clause 1 (reserved ids) or clause 2 (display suffixes), +as amended by amendment 1. **Case folding is full Unicode folding, and it comes from `unicase`.** `str::to_lowercase` is not folding: `ß` lowercases to itself and then @@ -1700,9 +1701,9 @@ becomes `_`, so `Straße` would slug to `stra_e` (clause 4, step 1: full case folding). The same goes for `ſ` (long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no dependencies, so it passes the wasm gate. Its `to_folded_case` applies the C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` → -`masse`. Most of what folding produces is stripped anyway: only an ASCII -letter survives step 3. Deriving that ASCII-producing subset of the table by -hand was rejected. It is a second copy of the Unicode data, and nothing would +`masse`. Most of what folding produces becomes `_` anyway: only ASCII +letters, digits and `-` survive step 2. Deriving that ASCII-producing subset of +the table by hand was rejected. It is a second copy of the Unicode data, and nothing would say when it drifted. **Slugify is the amendment's six steps** (ADR 007 amendment 1, clause 4): From 040b7070e305c6a0f2c6bf342abe732720937a09 Mon Sep 17 00:00:00 2001 From: "Claude (adr007-slug)" Date: Fri, 9 Oct 2026 13:09:18 -0700 Subject: [PATCH 4/4] test(core): pin the zeros tie-break, widen the id-syntax and suffix properties, refresh the amendment's built-code note (improvise-cq1q.14) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../adr/007-stable-identities-amendment-1.md | 11 +++-- context/test-evidence/core.md | 16 ++++++- crates/improvise-core/src/model/id.rs | 46 ++++++++++++++----- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/context/adr/007-stable-identities-amendment-1.md b/context/adr/007-stable-identities-amendment-1.md index 940b8e38..63ebfbb7 100644 --- a/context/adr/007-stable-identities-amendment-1.md +++ b/context/adr/007-stable-identities-amendment-1.md @@ -178,10 +178,11 @@ different thing that generates the same slug: the merged file declares that id t and the load is refused naming both. Stable identity across copies of a file is not promised (improvise-8tk5). -**Built code.** `crates/improvise-core/src/model/id.rs` implements the accepted steps -(`context/code/core.md` §20). A follow-up must replace rather than strip, collapse -`_`, trim a trailing `_` and both ends' `-`, prepend `n`, make `claim` refuse what a -generated id could not be, and add the virtual views and `IdKind::Group`. +**Built code.** `crates/improvise-core/src/model/id.rs` implements this clause +(`context/code/core.md` §20): `slugify` follows the six steps, `is_id_syntax` is the +syntax, `claim` refuses what a generated id could not be, `VIRTUAL_IDS` holds the five +virtual ids, `IdKind::Group` exists, and `natural_cmp` and `display_suffixes` implement +natural order and clause 2's suffixes. No other code calls them yet. ### 5. Persistence @@ -310,7 +311,7 @@ therefore shows B as `east-1`, never as a bare `east`. - Scripts that name things by display break and must be rewritten over ids. - Two measures can share a display, and are shown as `Profit` and `Profit #2`. - Old files' `_Measure` declarations and hyphen- or digit-led names change on first save. -- `id.rs` and its tests need the follow-up in clause 4. +- `id.rs` implements clause 4; the code that sorts, shows and creates ids does not call it yet. ## Open questions diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index b25328ca..53eb3eb8 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1955,12 +1955,24 @@ The property over arbitrary displays: a non-empty slug satisfies `generated_ids_are_distinct_well_formed_and_never_reserved`): no `_` collapse, no `n` prefix, keeping a trailing `_` or `-`. -### model::id::tests::id_syntax_agrees_with_its_regular_expression +### model::id::tests::id_syntax_agrees_with_an_independent_checker -`is_id_syntax` against the spelled-out rule over `[a-c0-9_-]{0,8}`. Rivals: +`is_id_syntax` against a hand-written scanner of `^[a-z](_?[a-z0-9-])*$` that +does not end in `-`, over `[a-cA-C0-9 _%é-]{0,8}`, so uppercase, space, symbols +and non-ASCII are reached. Dropping the alphabet check also fails it. Rivals: allowing `__`, allowing a leading digit and allowing a trailing `-` each fail here and in `claim_refuses_what_a_generated_id_could_not_be`. +### model::id::tests::natural_order_decides_the_zeros_tie_at_its_own_run_not_after_the_rest + +Rival: compare leading zeros only after every run compares equal in value. +`a-7c` then sorts after `a-007b`, and this test fails alone. + +### model::id::tests::suffixed_displays_are_distinct_when_literals_already_hold_suffixes + +Displays are drawn from `A`, `A #2`, `A #3` and `B`, so literal suffixes meet +generated ones. Rival: numbering without the skip fails it. + ### model::id::tests::claim_refuses_what_a_generated_id_could_not_be Rival: `claim` keeps the old alphabet-only check. `-x`, `x-`, `2024`, `a__b`, diff --git a/crates/improvise-core/src/model/id.rs b/crates/improvise-core/src/model/id.rs index dfdea0f1..2bb87b84 100644 --- a/crates/improvise-core/src/model/id.rs +++ b/crates/improvise-core/src/model/id.rs @@ -424,6 +424,13 @@ mod tests { assert_eq!(natural_cmp("x000", "x0"), Ordering::Greater); } + #[test] + fn natural_order_decides_the_zeros_tie_at_its_own_run_not_after_the_rest() { + assert_eq!(natural_cmp("a-7c", "a-007b"), Ordering::Less); + assert_eq!(natural_cmp("a-7b", "a-007a"), Ordering::Less); + assert_eq!(natural_cmp("a-007a", "a-7b"), Ordering::Greater); + } + #[test] fn natural_order_puts_the_id_that_runs_out_of_runs_first() { assert_eq!(natural_cmp("a", "a-1"), Ordering::Less); @@ -484,6 +491,23 @@ mod tests { assert!(display_suffixes(&[]).is_empty()); } + /// `^[a-z](_?[a-z0-9-])*$` and not ending in `-`, without the crate's own helpers. + fn regex_free_match(id: &str) -> bool { + let mut cs = id.chars().peekable(); + if !matches!(cs.next(), Some('a'..='z')) { + return false; + } + while cs.peek().is_some() { + if cs.peek() == Some(&'_') { + cs.next(); + } + if !matches!(cs.next(), Some('a'..='z' | '0'..='9' | '-')) { + return false; + } + } + !id.ends_with('-') + } + fn id_text() -> impl Strategy { "[a-c0-3_-]{0,7}" } @@ -497,11 +521,8 @@ mod tests { } #[test] - fn id_syntax_agrees_with_its_regular_expression(id in "[a-c0-9_-]{0,8}") { - let spelled = id.starts_with(|c: char| c.is_ascii_lowercase()) - && !id.ends_with(['_', '-']) - && !id.contains("__"); - prop_assert_eq!(is_id_syntax(&id), spelled); + fn id_syntax_agrees_with_an_independent_checker(id in "[a-cA-C0-9 _%é-]{0,8}") { + prop_assert_eq!(is_id_syntax(&id), regex_free_match(&id), "{}", id); } #[test] @@ -514,13 +535,16 @@ mod tests { } #[test] - fn suffixed_displays_are_distinct_when_the_literals_are_not_already_clashing( - ids in proptest::collection::hash_set("[a-c][0-9]{0,2}", 0..8), - display in "[AB]( #[2-4])?" + fn suffixed_displays_are_distinct_when_literals_already_hold_suffixes( + shared in proptest::collection::vec(0usize..4, 0..8) ) { - let mut ids: Vec = ids.into_iter().collect(); - ids.sort_by(|a, b| natural_cmp(a, b)); - let items: Vec<(&str, &str)> = ids.iter().map(|i| (i.as_str(), display.as_str())).collect(); + let displays = ["A", "A #2", "A #3", "B"]; + let ids: Vec = (0..shared.len()).map(|n| format!("x-{n}")).collect(); + let items: Vec<(&str, &str)> = ids + .iter() + .zip(&shared) + .map(|(i, &d)| (i.as_str(), displays[d])) + .collect(); let shown = display_suffixes(&items); let distinct: HashSet<&String> = shown.iter().collect(); prop_assert_eq!(distinct.len(), shown.len());