Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/spec/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ claim without a proving merge.
## Coverage

The denominator below is the current mirrored `docs/spec/mirror/REQUIREMENTS_REGISTRY.csv`
data-row count (25 as of `SPEC_CHANGELOG.md` revision `VIS-CADENCE-001`, which added
`REQ-VISUALIZATION-002..005`). Recompute it from the registry itself whenever either
data-row count (32 as of `SPEC_CHANGELOG.md` revision `HANDOFF-REPAIR-003`, which is the
latest revision naming a `REQ_ID`). Recompute it from the registry itself whenever either
file changes; never carry a historical total forward independently of the registry.

| REQ ID | Status | Issue | Merged in | Proving test |
Expand All @@ -39,13 +39,13 @@ file changes; never carry a historical total forward independently of the regist
| REQ-CORE-001 | `IMPLEMENTED` | #46 | #48, `58a0b49156131ec81fcb23d357f7e15b327bde4d` | `src/domain/id.test.ts` (13-kind prefix table, per-kind sequential allocation, duplicate-creation-key rejection, cross-run replay determinism, stable creation-key-order contract independent of array/Map build order, `CORE-T16` retirement/non-reuse including wrong-kind and double-retire rejection, and the corrected-head regressions verified at `c584d830ce46d68a398c089b06d72b1ced7b33c0` — failure-atomic batch allocation and runtime kind/creation-key validation) plus `src/domain/id.typecheck.test.ts` (`tsc --noEmit` regressions proving distinct ID kinds and bare strings are not mutually assignable). See `docs/adr/0003-canonical-identity-and-allocation.md` for the design. |
| REQ-CORE-002 | `IMPLEMENTED` | #57 | #58, `002343805d14a4d3b43faf089aef9f7d52da9d4a` | `src/domain/numeric.test.ts` (rejects `NaN`/`+Infinity`/`-Infinity`/non-number input, accepts ordinary finite numbers including `0` and negative values) and `src/domain/ordering.test.ts` (`stableOrderBy`/`sortByPersistentId` produce an identical normalized result from differently-shuffled input — `CORE-T2`-shaped evidence — using `src/domain/id.ts` IDs as the concrete key type; a dedicated non-identity assertion proves the sort actually reorders rather than passing vacuously). |
| REQ-CONFIG-001 | `IMPLEMENTED` | #64 | #67, `493da16e53e58e78a830ad44f1da627f9196742c` | `src/config/configLayers.typecheck.test.ts` (`tsc --noEmit` regressions proving `RunOptions`, `SimulationConfig`, `ScenarioDefinition` and `DefinitionPack` are structurally distinct, non-interchangeable types) and `src/config/validation.test.ts` (`assertNoBehavioralOverrides` accepts a minimal well-formed scenario and the legitimate array-shaped `markets`/`clans` seed fields; rejects a `SimulationConfig`-owned behavioral key including the `markets`/`clans` name-collision case where the smuggled value is an object rather than an array; rejects an arbitrary unknown key; rejects a non-object candidate). |
| REQ-CONFIG-002 | `IN_PROGRESS` | #69 | pending — implemented on branch `claude/issue-69-keyed-rng`, not yet merged | `src/config/rng.test.ts` (`deriveKeyedRandom` reproduces the same value across repeated calls with the same key; yields identical per-key results regardless of evaluation order over a fixed key set; changes value when seed, tick, phase or key individually differ; does not collide across a naive delimiter-ambiguous key composition; returns a value in `[0, 1)`; rejects a non-finite seed/tick and an empty phase/key). Not yet mergeable evidence: promote to `IMPLEMENTED` only once a pull request referencing this row actually merges. |
| REQ-CONFIG-002 | `IMPLEMENTED` | #69 | #70, `005d0a553dbc685b8bf554ff0ae9d802a7ac04fd` | `src/config/rng.test.ts` (`deriveKeyedRandom` reproduces the same value across repeated calls with the same key; yields identical per-key results regardless of evaluation order over a fixed key set; changes value when seed, tick, phase or key individually differ; does not collide across a naive delimiter-ambiguous key composition; returns a value in `[0, 1)`; rejects a non-finite seed/tick and an empty phase/key). |
| REQ-CORE-003 | `IN_PROGRESS` | #72 | pending — implemented on branch `claude/issue-72-core-registries`, not yet merged | `src/domain/worldRegistries.test.ts` (`buildWorldRegistries` creates exactly the scenario-defined entity counts for Region/State/Currency/Clan/PopulationCohort/ProductionUnit; leaves `transportLinks`/`markets` legitimately empty when the scenario omits or empties those seed lists; every entry is keyed by its own allocated ID with the correct id-kind prefix and no duplicate ID within or across registries; repeated genesis with the same scenario and a fresh allocator is field-equivalent) and `src/domain/definitionRegistry.test.ts` (`buildDefinitionRegistry` carries a `DefinitionPack`'s `goods`/`recipes`/`eventDefinitions`/`metricDefinitions` through unchanged, including the empty case). Not yet mergeable evidence: promote to `IMPLEMENTED` only once a pull request referencing this row actually merges. |

**Summary: 8 of 25 requirements implemented; 1 in progress.** The other 16
requirement identifiers in `docs/spec/mirror/REQUIREMENTS_REGISTRY.csv` — including
the remaining `REQ-VISUALIZATION-002, 004, 005` — are not yet mapped to this table;
that mapping, and their own implementation evidence, is separate follow-up work, not
evidence that they are unimplemented or implemented.
**Summary: 9 of 32 requirements implemented; 1 in progress.** The other 22
requirement identifiers in `docs/spec/mirror/REQUIREMENTS_REGISTRY.csv` are not yet
mapped to this table; that mapping, and their own implementation evidence, is
separate follow-up work, not evidence that they are unimplemented or implemented.

## Pre-existing behavior

Expand Down
40 changes: 40 additions & 0 deletions src/domain/definitionRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";

import type { DefinitionPack } from "../config/definitionPack";
import { buildDefinitionRegistry } from "./definitionRegistry";

describe("buildDefinitionRegistry", () => {
it("carries every DefinitionPack definitions field through unchanged", () => {
const definitionPack: DefinitionPack = {
id: "fixture-pack",
version: "1",
goods: { "good:1": {} } as DefinitionPack["goods"],
recipes: { "recipe-a": {} },
eventDefinitions: { "event-a": {} },
metricDefinitions: { "metric-a": {} },
};

const registry = buildDefinitionRegistry(definitionPack);

expect(registry.goods).toBe(definitionPack.goods);
expect(registry.recipes).toBe(definitionPack.recipes);
expect(registry.eventDefinitions).toBe(definitionPack.eventDefinitions);
expect(registry.metricDefinitions).toBe(definitionPack.metricDefinitions);
});

it("leaves an empty definition pack as an empty registry, not an error", () => {
const definitionPack: DefinitionPack = {
id: "empty-pack",
version: "1",
goods: {},
recipes: {},
eventDefinitions: {},
metricDefinitions: {},
};

const registry = buildDefinitionRegistry(definitionPack);

expect(Object.keys(registry.goods)).toHaveLength(0);
expect(Object.keys(registry.recipes)).toHaveLength(0);
});
});
27 changes: 27 additions & 0 deletions src/domain/definitionRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Definitions registry (REQ-CORE-003).
*
* See `docs/spec/mirror/06 - Handoff/01 — CORE_SCHEMA_AND_LIFECYCLES.md`
* section 6: `DefinitionRegistry` holds immutable, scenario-versioned
* content/reference definitions distinct from live world-entity instances —
* `goods`, `recipes`, `eventDefinitions`, `metricDefinitions`. `DefinitionPack`
* (`REQ-CONFIG-001`, `../config/definitionPack.ts`) already declares exactly
* these four fields with matching key/value shapes, so this registry is a
* typed, read-only view over a `DefinitionPack` rather than new storage.
*/
import type { DefinitionPack } from "../config/definitionPack";

export type DefinitionRegistry = Pick<
DefinitionPack,
"goods" | "recipes" | "eventDefinitions" | "metricDefinitions"
>;

/** Builds the definitions registry from `definitionPack`, unchanged. */
export function buildDefinitionRegistry(definitionPack: DefinitionPack): DefinitionRegistry {
return {
goods: definitionPack.goods,
recipes: definitionPack.recipes,
eventDefinitions: definitionPack.eventDefinitions,
metricDefinitions: definitionPack.metricDefinitions,
};
}
6 changes: 6 additions & 0 deletions src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* REQ-CORE-001 (see ./id.ts) is the first canonical behavior to land here.
* REQ-CORE-002 (see ./numeric.ts, ./ordering.ts) adds the finite-number and
* deterministic-ordering primitives every later canonical subsystem builds on.
* REQ-CORE-003 (see ./worldRegistries.ts, ./definitionRegistry.ts) adds the
* stable, ID-keyed world entity and definitions registries.
*/
export const DOMAIN_MODULE_AREA = "domain" as const;

Expand Down Expand Up @@ -35,3 +37,7 @@ export {
export { assertFiniteCanonicalNumber, isFiniteCanonicalNumber } from "./numeric";

export { sortByPersistentId, stableOrderBy } from "./ordering";

export { buildWorldRegistries, type RegistryEntry, type WorldRegistries } from "./worldRegistries";

export { buildDefinitionRegistry, type DefinitionRegistry } from "./definitionRegistry";
86 changes: 86 additions & 0 deletions src/domain/worldRegistries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";

import type { ScenarioDefinition } from "../config/scenarioDefinition";
import { createIdAllocator } from "./id";
import { buildWorldRegistries } from "./worldRegistries";

/** A fixture scenario with distinct, non-zero counts per registry, plus two legitimately empty ones. */
function fixtureScenario(): ScenarioDefinition {
return {
id: "fixture-scenario",
version: "1",
name: "Fixture",
description: "REQ-CORE-003 registry-population fixture",
definitionPackId: "fixture-pack",
geography: [{}, {}, {}],
transportLinks: [],
states: [{}, {}],
currencies: [{}],
monetaryAuthorities: [],
clans: [{}, {}],
cohorts: [{}, {}, {}, {}],
productionUnits: [{}, {}, {}],
// markets, bonds, initialEvents deliberately omitted: legitimately empty/non-active.
};
}

describe("buildWorldRegistries", () => {
it("creates exactly the scenario-defined entity counts", () => {
const registries = buildWorldRegistries(fixtureScenario(), createIdAllocator());

expect(registries.regions.size).toBe(3);
expect(registries.states.size).toBe(2);
expect(registries.currencies.size).toBe(1);
expect(registries.clans.size).toBe(2);
expect(registries.cohorts.size).toBe(4);
expect(registries.productionUnits.size).toBe(3);
});

it("leaves a registry legitimately empty when the scenario omits or empties its seed list", () => {
const registries = buildWorldRegistries(fixtureScenario(), createIdAllocator());

expect(registries.transportLinks.size).toBe(0);
expect(registries.markets.size).toBe(0);
});

it("keys every entry by its own allocated ID with the correct id-kind prefix, no duplicates", () => {
const registries = buildWorldRegistries(fixtureScenario(), createIdAllocator());

for (const [id, entry] of registries.regions) {
expect(entry.id).toBe(id);
expect(id).toMatch(/^r:\d+$/);
}
expect(new Set(registries.regions.keys()).size).toBe(registries.regions.size);

for (const [id, entry] of registries.productionUnits) {
expect(entry.id).toBe(id);
expect(id).toMatch(/^pu:\d+$/);
}
});

it("does not allocate the same ID to two different entities across registries", () => {
const registries = buildWorldRegistries(fixtureScenario(), createIdAllocator());

const allIds = [
...registries.regions.keys(),
...registries.states.keys(),
...registries.currencies.keys(),
...registries.clans.keys(),
...registries.cohorts.keys(),
...registries.productionUnits.keys(),
...registries.markets.keys(),
...registries.transportLinks.keys(),
];
expect(new Set(allIds).size).toBe(allIds.length);
});

it("is field-equivalent across repeated genesis with the same scenario and a fresh allocator", () => {
const scenario = fixtureScenario();
const first = buildWorldRegistries(scenario, createIdAllocator());
const second = buildWorldRegistries(scenario, createIdAllocator());

expect([...first.regions.keys()]).toEqual([...second.regions.keys()]);
expect([...first.cohorts.keys()]).toEqual([...second.cohorts.keys()]);
expect([...first.productionUnits.keys()]).toEqual([...second.productionUnits.keys()]);
});
});
125 changes: 125 additions & 0 deletions src/domain/worldRegistries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Stable canonical entity registries (REQ-CORE-003).
*
* See `docs/spec/mirror/06 - Handoff/11 — REPOSITORY_MIGRATION_AND_MILESTONE_GATES.md`
* Milestone 1 ("Implement stable registries for Region, State, Currency, Clan,
* PopulationCohort, ProductionUnit, LocalMarket, TransportLink and definitions,
* allowing initially empty/non-active subsystems where specified") and
* `docs/spec/mirror/06 - Handoff/01 — CORE_SCHEMA_AND_LIFECYCLES.md` section 3
* (`WorldState`'s `Record<Id, State>` registries) and section 5 (per-entity
* identity/registry ownership rules).
*
* This module owns only the registry substrate — ID-keyed, create-once,
* retrieve-by-ID storage built from a scenario's seed lists — not economic
* behavior, opening-stock reconciliation (`REQ-CONFIG-004`) or the concrete
* baseline definition pack / scenario construction (`REQ-CONFIG-003`). Each
* entry currently carries only its allocated ID plus whatever fields the
* corresponding `*Seed` type already declares; `ScenarioDefinition`'s seed
* types are still empty placeholders pending `REQ-CONFIG-003`, so registries
* built today are legitimately ID-only until that requirement lands.
*/
import type {
ClanId,
CohortId,
CurrencyId,
IdAllocator,
MarketId,
ProductionUnitId,
RegionId,
StateId,
TransportLinkId,
} from "./id";
import type {
ClanSeed,
CohortSeed,
CurrencySeed,
MarketSeed,
ProductionUnitSeed,
RegionSeed,
ScenarioDefinition,
StateSeed,
TransportLinkSeed,
} from "../config/scenarioDefinition";

/** One registry entry: the entity's allocated ID plus its declared seed fields. */
export type RegistryEntry<Id extends string, Seed extends object> = Seed & { readonly id: Id };

/**
* Builds one ID-keyed registry from a scenario's seed list, in the list's own
* order. A registry built from an omitted or empty seed list is legitimately
* empty, not an error — the milestone bullet explicitly allows initially
* empty/non-active subsystems (for example no `TransportLink` before
* Milestone 5's transport work).
*
* Determinism: this walks `seeds` strictly in array order and allocates
* exactly one ID per element via `allocate`, so the same scenario fed to a
* fresh allocator always reproduces the same ID-to-seed mapping — the
* "repeated genesis with same seed is byte/field-equivalent" Gate M1
* requirement. `entityKind` only distinguishes creation keys across
* registries; it does not by itself guarantee cross-registry uniqueness
* (the `IdAllocator` already scopes creation keys per ID kind).
*/
function buildRegistry<Id extends string, Seed extends object>(
entityKind: string,
seeds: readonly Seed[] | undefined,
allocate: (creationKey: string) => Id,
): ReadonlyMap<Id, RegistryEntry<Id, Seed>> {
const entries = new Map<Id, RegistryEntry<Id, Seed>>();
(seeds ?? []).forEach((seed, index) => {
const id = allocate(`${entityKind}:${index}`);
entries.set(id, { ...seed, id });
});
return entries;
}

/**
* Stable, ID-keyed canonical world entity registries for exactly the
* Milestone 1 registries bullet's list: Region, State, Currency, Clan,
* PopulationCohort, ProductionUnit, LocalMarket, TransportLink.
*/
export interface WorldRegistries {
readonly regions: ReadonlyMap<RegionId, RegistryEntry<RegionId, RegionSeed>>;
readonly states: ReadonlyMap<StateId, RegistryEntry<StateId, StateSeed>>;
readonly currencies: ReadonlyMap<CurrencyId, RegistryEntry<CurrencyId, CurrencySeed>>;
readonly clans: ReadonlyMap<ClanId, RegistryEntry<ClanId, ClanSeed>>;
readonly cohorts: ReadonlyMap<CohortId, RegistryEntry<CohortId, CohortSeed>>;
readonly productionUnits: ReadonlyMap<
ProductionUnitId,
RegistryEntry<ProductionUnitId, ProductionUnitSeed>
>;
readonly markets: ReadonlyMap<MarketId, RegistryEntry<MarketId, MarketSeed>>;
readonly transportLinks: ReadonlyMap<
TransportLinkId,
RegistryEntry<TransportLinkId, TransportLinkSeed>
>;
}

/**
* Builds every Milestone 1 world entity registry from `scenario`'s seed
* lists, allocating each entity's ID through `allocator`. Contains no
* economic behavior, opening-stock reconciliation or RNG-driven variation —
* only identity allocation and ID-keyed storage.
*/
export function buildWorldRegistries(
scenario: ScenarioDefinition,
allocator: IdAllocator,
): WorldRegistries {
return {
regions: buildRegistry("region", scenario.geography, (key) =>
allocator.allocate("Region", key),
),
states: buildRegistry("state", scenario.states, (key) => allocator.allocate("State", key)),
currencies: buildRegistry("currency", scenario.currencies, (key) =>
allocator.allocate("Currency", key),
),
clans: buildRegistry("clan", scenario.clans, (key) => allocator.allocate("Clan", key)),
cohorts: buildRegistry("cohort", scenario.cohorts, (key) => allocator.allocate("Cohort", key)),
productionUnits: buildRegistry("productionUnit", scenario.productionUnits, (key) =>
allocator.allocate("ProductionUnit", key),
),
markets: buildRegistry("market", scenario.markets, (key) => allocator.allocate("Market", key)),
transportLinks: buildRegistry("transportLink", scenario.transportLinks, (key) =>
allocator.allocate("TransportLink", key),
),
};
}
Loading