feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)

Two additive D7 catalogue rules, surfaced while writing the website seed
docs. No change to the type fallback, executor, or grammar.

#33 — year-like int columns. `published`/`birth_year` were just `int`, so
they fell to the unbounded int path and produced nonsense (`9419`). Add an
int-gated year rule (after the quantity rule, so `year_count` stays a
count): `year`/`*_year`/`published`/`founded` -> a bounded 1950-2025 year
(new `YearRecent`), or the dob-style birth window 1945-2007 for
`birth`/`born`/`dob` (new `YearBirth`). Plain int; not added to the D9
named-generator vocabulary.

#34 — conventional choice sets. A few enum-ish names have a near-canonical
small set that reads far better than lorem text. Add a type-gated PickFrom
lookup (reusing the existing generator): priority/prio, severity,
rating/stars. `status` is deliberately excluded (values too
domain-specific) and keeps the D12 advisory; a user IN-CHECK still wins.
`priority` leaves ENUM_TOKENS.

ADR-0048 Amendment 1; +8 tests (incl. a column-fill integration test that
also closes a pre-existing gap on that path).
This commit is contained in:
claude@clouddev1
2026-06-12 20:36:20 +00:00
parent fde50ce3bf
commit deb0948d6c
7 changed files with 374 additions and 4 deletions
+52
View File
@@ -31,6 +31,16 @@ const RECENT_WINDOW_DAYS: i64 = 3 * 365;
const ADULT_MIN_DAYS: i64 = 18 * 365;
const ADULT_MAX_DAYS: i64 = 80 * 365;
/// Year windows for the `int`-typed year heuristics (issue #33),
/// expressed relative to [`REF_YEAR`] so they advance with releases —
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
/// `YearRecent` spans ~75 years (19502025 at REF_YEAR=2025), wide
/// enough for `published` / `founded` / `release_year`; `YearBirth`
/// mirrors the adult birth window (19452007).
const YEAR_RECENT_SPAN: i32 = 75;
const YEAR_BIRTH_MIN_AGE: i32 = 18;
const YEAR_BIRTH_MAX_AGE: i32 = 80;
/// Produce one value for `generator` against destination type `ty`.
#[must_use]
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
@@ -71,6 +81,13 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
@@ -489,6 +506,41 @@ mod tests {
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
}
#[test]
fn year_generators_stay_within_their_bounded_windows() {
// Issue #33: both year generators emit a plain `int` inside a
// bounded, plausible window — never the unbounded-int nonsense.
let mut rng = make_rng(Some(7));
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
else {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
else {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
}
}
#[test]
fn year_generators_are_deterministic_for_a_fixed_seed() {
assert_eq!(
gen_once(&Generator::YearRecent, Type::Int, 42),
gen_once(&Generator::YearRecent, Type::Int, 42),
);
assert_eq!(
gen_once(&Generator::YearBirth, Type::Int, 42),
gen_once(&Generator::YearBirth, Type::Int, 42),
);
}
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };