Skip to content

Commit b5b2c74

Browse files
Merge pull request #36 from ghostintheshell-192/feature/degenerate-levels
feat(engine): degenerate levels — normalize(), the content algebra, the min-disks split (§4, §6, §8)
2 parents 488044a + a0902ac commit b5b2c74

15 files changed

Lines changed: 769 additions & 54 deletions

File tree

.development/ARCHITECTURE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ kernel rules and never regenerated from the engine.
4949

5050
### src/engine
5151
- `catalog.js`
52+
- `content.js`
5253
- `graph.js`
5354
- `layout.js`
5455
- `levels.js`
@@ -78,11 +79,13 @@ kernel rules and never regenerated from the engine.
7879
- `catalog.test.js` — headless tests for the component catalogue (engine/catalog.js). Run with: node catalog.test.js
7980
- `challenge-data.test.js` — validates the REAL challenge YAML files. Run with: node challenge-data.test.js (uses python3 + pyyaml to read YAML; this repo is zero-dependency and Node has no YAML parser, so python is the reader....
8081
- `challenge.test.js` — headless tests for the requirement-satisfaction win-check. Run with: node challenge.test.js
82+
- `collapses-oracle.test.js` — the content algebra (engine/content.js) against the declared `collapsesTo` rules, in both directions (degenerate-levels §6, §10). Run with: node collapses-oracle.test.js
8183
- `components-data.test.js` — validates the REAL component YAML files and keeps the headless fixture aligned with them. Run with: node components-data.test.js (uses python3 + pyyaml to read YAML; this repo is zero-dependency and...
8284
- `graph.test.js` — headless tests for the control-path graph module. Run with: node graph.test.js
8385
- `layout-golden.test.js` — golden-table verification for all parity algorithms. Run with: node layout-golden.test.js
8486
- `levels-oracle.test.js` — the hand-written recognizer as ORACLE for the data-driven one. Run with: node levels-oracle.test.js
8587
- `levels.test.js` — headless tests for the level catalogue and the shape matcher (engine/levels.js). Run with: node levels.test.js
88+
- `model-normalize.test.js` — headless tests for `normalize()` and the two-box `analyze()` (specs/planned/degenerate-levels.md §4, §7, §10 "Recognition"). Run with: node model-normalize.test.js
8689
- `model-perf.test.js` — headless tests for the performance derivation (§4b). Run with: node model-perf.test.js
8790
- `model-recognize.test.js` — headless tests for the level RECOGNIZER (§4). Run with: node model-recognize.test.js
8891
- `raid-levels-data.test.js` — validates the REAL raid-levels YAML files and keeps the headless fixture aligned with them. Run with: node raid-levels-data.test.js (uses python3 + pyyaml to read YAML; this repo is zero-dependency ...

.development/reference/refusal-points.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,15 @@ that; it accumulated.
7777

7878
## 3. It accepts and explains — the validator
7979

80-
Seven rules, all in the declarative registry in `validator.js`. Each states its identity,
81-
severity, layer and source once; `validate` stamps them on, so a rule cannot disagree
82-
with its own registration.
80+
Seven rules at the time of the census (ten on 2026-09-05: `level-advisory`, `write-hole`
81+
and `level-collapse` came after), all in the declarative registry in `validator.js`. Each
82+
states its identity, severity, layer and source once; `validate` stamps them on, so a
83+
rule cannot disagree with its own registration.
8384

8485
| code | severity | what it teaches |
8586
|---|---|---|
86-
| `min-disks` | hard | a level has a minimum, read from the level's own file |
87+
| `min-disks` | hard | below `minDisksToRun` the real system does not start the level — the kernel line is cited from the level's own file (since 2026-09-05; before, the canonical `minDisks`) |
88+
| `level-collapse` | soft | below `minDisks` the build still runs, as a simpler level — `normalize()`'s trace, one violation per rewrite, with the level file's `because` (2026-09-05, degenerate-levels §8) |
8789
| `cross-axis-near-far-offset` | hard | near/far/offset are mdadm layouts — they exist only under Linux software RAID |
8890
| `nvme-backplane` | hard | NVMe talks straight to PCIe; it bypasses the backplane |
8991
| `engine-single-point` | hard | the RAID engine sits at exactly one point on the path |

.development/specs/planned/degenerate-levels.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ box 2 RAID 1
8080
The diff is a trace, in the way a compiler keeps a log of the simplifications it applied.
8181
It is not a function of two names.
8282

83+
Implemented 2026-09-05 as `RaidModel.normalize(tree, levels) → { tree, trace }`: a new
84+
tree (the composed one is never touched; the disks are shared), one trace entry per
85+
rewrite — `rule` (`collapse` or `absorb`), the `level` whose file declared it, the
86+
`nodeId`, the shape before and after, `because`, `source`. `analyze()` returns box 1 at
87+
the top as before, the numbers computed on the normalised tree (§7), and box 2 as
88+
`runs: { level, …, tree, trace }`. One consequence worth knowing: a two-disk RAID 5 now
89+
*reads* as a mirror too — `readClass` medium, not high — because one read of a two-disk
90+
parity stripe touches one disk, which the composed tree's arithmetic (width 2) got wrong.
91+
8392
## 5. The data: `collapsesTo`, and two minimums
8493

8594
Per ADR-002 the rules are data. They live on the level file whose shape they narrow,
@@ -131,7 +140,16 @@ disk-count constraint: three disks never have RAID 10's shape (even), they are R
131140
so RAID 10 owes no entry at 3 and an entry there could never fire (`levels.js` refuses it). Below `minDisksToRun` an entry is optional and useful
132141
(RAID 6 with 3 disks: *it would be a three-way mirror, and Linux does not start it*).
133142

134-
**Leaf levels only.** A nested level (`members: arrays`) declares none of the three keys,
143+
**`absorbsNested`** — the one rule the width does not express. A RAID 51 whose spans have
144+
collapsed into pairs is a mirror *of mirrors*, and the catalogue has no name for that
145+
shape; §3 says it is a four-way RAID 1. So a leaf level may declare that nesting its
146+
shape inside itself changes nothing, and `normalize()` then folds the members' disks
147+
into the node (`raid1.yaml`: *a mirror of mirrors is one mirror*, with its `because` and
148+
`source`). Declared, like the collapses, so the engine names no level; only the plain
149+
mirror declares it — a stripe of stripes is left alone, because the catalogue has no
150+
RAID 00 and no claim to make about it. Added 2026-09-05 while implementing §4.
151+
152+
**Leaf levels only.** A nested level (`members: arrays`) declares none of the keys,
135153
and `levels.js` refuses them there: its collapse *is* the recursion of §3 — the spans
136154
carry the rule, and a copy on the outer level would be the same fact stated twice, free
137155
to drift. Where nothing runs below the minimum (JBOD, RAID 0, RAID 1: the universal
@@ -208,6 +226,15 @@ treats one as RAID 5 (XOR computed for nothing, a RAID 5 growth path). The algeb
208226
*"the data lands identically"*, which is part of the truth. The other part — what a real
209227
system does with it — is the `source:` on the declared rule.
210228

229+
Implemented 2026-09-05 as `src/engine/content.js` (tests only — no script tag loads it)
230+
and `tests/collapses-oracle.test.js`. One refinement the implementation forced: a plain
231+
mirror is read off the *copies*, not off the drawn rows — every disk holds every segment
232+
— because `far` draws its copies in other rows and is a mirror all the same; the rows are
233+
consulted only to tell a stripe from a concatenation, the one distinction the contents
234+
cannot make. The oracle runs every leaf level from 2 disks to two past its minimum, checks
235+
that at and above the minimum the algebra reads the level as itself, and prints what it
236+
found below it: `raid5 @2`, `raid6 @3`, `raid10 @2` → `linear+mirror`. Nothing else.
237+
211238
**Where it lives.** The core (symbolic content, equality classes, copy counting) knows
212239
nothing about RAID. The RAID-specific part is *how a role derives its content* — data is
213240
itself, P is the XOR, Q carries `gⁱ` — and that sits next to `layout.js`, which is
@@ -262,6 +289,15 @@ does not, and the button says why.
262289
`_firstIssue`). With `minDisksToRun: 2` on RAID 5 the hard half never fires for it, which
263290
is correct.
264291

292+
Implemented 2026-09-05 in `validator.js`: `min-disks` (hard) now compares a leaf span to
293+
its level's `minDisksToRun` and cites `minDisksToRunSource` — *Linux does not start it
294+
below 4 (drivers/md/raid5.c setup_conf(): …)*; a level file without the key falls back
295+
to `minDisks`, the rule as it was. `level-collapse` (soft, new) is `normalize()`'s trace
296+
read as violations, one per rewrite, on the node that was rewritten: *This array is a
297+
RAID 5 with 2 disks — what runs is a RAID 1: with one data block per stripe the parity
298+
is that block itself…*. The soft text names what runs through the catalogue (`runsAs` on
299+
the trace entry), never through a table of shape names in code.
300+
265301
## 9. UI — to decide in the browser
266302

267303
Not decided here; the candidates, in order of how much they show rather than tell:

data/raid-levels/raid1.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ minDisks: 2
1515
minDisksToRun: 2
1616
minDisksToRunSource: "structural — an array of one member is that member; the sandbox refuses it before any level is named (spec §6, the universal ≥ 2)"
1717

18+
# A mirror whose members are mirrors is one mirror: this is what lets a RAID 51 whose
19+
# spans collapsed into pairs read as a four-way RAID 1 (degenerate-levels §3, §4).
20+
absorbsNested:
21+
because: "a mirror of mirrors is one mirror — every disk still holds a full copy"
22+
source: "content algebra (degenerate-levels §6): every cell of every member carries the same segment, so the copy count is the disk count"
23+
1824
# Reference only — documentation, not data (see tech-debt/level-numbers-duplicated-untested.md).
1925
# faultToleranceAtMinimum is the level's tolerance AT ITS MINIMUM DISK COUNT, not the
2026
# array's — RAID 1 is exactly where this bites: an n-way mirror with n > 2 survives

src/engine/content.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// @ts-check
2+
/**
3+
* content.js — RAID Sandbox: the content algebra (specs/planned/degenerate-levels.md §6).
4+
*
5+
* TESTS ONLY. The game never loads this file (no script tag in index.html):
6+
* the declared `collapsesTo` rules run at runtime, and this is the independent
7+
* derivation that checks them (§7: "the test is the computation done in advance").
8+
*
9+
* layout.js gives every cell a ROLE (data / P / Q / mirror) and a segment. This
10+
* file gives every cell its symbolic CONTENT — a vector over the data segments
11+
* of its stripe:
12+
*
13+
* data, segment 3 → {3: 1}
14+
* P of a stripe holding 0, 1, 2 → {0: 1, 1: 1, 2: 1} the XOR
15+
* Q of the same stripe → {0: g⁰, 1: g¹, 2: g²}
16+
* P of a stripe holding 0 alone → {0: 1} it IS D0
17+
* Q of the same → {0: g⁰} = {0: 1} it IS D0
18+
* a mirror cell of segment 3 → {3: 1}
19+
*
20+
* No Galois-field arithmetic: only that g⁰ = 1, that gⁱ ≠ gʲ for i ≠ j, and that a
21+
* combination of two or more terms is never a single block. From the contents:
22+
*
23+
* - two cells on different disks with the same content are COPIES — by content,
24+
* not by position, so `far` is a mirror exactly like `near`;
25+
* - the number of copies per segment and the cells with two or more terms (real
26+
* parity) describe the array's behaviour with no names at all.
27+
*
28+
* `behaviour(node)` turns that description into the shape it amounts to — the
29+
* same {segmentation, redundancy} vocabulary the level files use — so the oracle
30+
* (collapses-oracle.test.js) can hold the declared rules to it in both directions.
31+
*
32+
* Its limit, kept explicit (§6): equivalence of content is not identity. The
33+
* algebra proves "the data lands identically"; what a real system does with that
34+
* is the `source:` on the declared rule, not this file's business.
35+
*
36+
* The core (contents, equality, copy counting) knows nothing about RAID. The
37+
* RAID-specific part — how a role derives its content — is `contentOf`, and it
38+
* sits next to layout.js for the same reason layout.js is ADR-002's declared
39+
* exception: the golden tables bind both to the kernel.
40+
*/
41+
42+
(function (/** @type {any} */ root) { // the UMD host: window, or Node's global
43+
'use strict';
44+
45+
const Layout = (typeof require !== 'undefined') ? require('./layout.js') : root.RaidLayout;
46+
47+
// ---------------------------------------------------------------------------
48+
// CONTENTS — one symbolic vector per cell
49+
// ---------------------------------------------------------------------------
50+
51+
/** A term's coefficient: 1, or gⁱ for i ≥ 1 (g⁰ is written 1 — that is the whole point). */
52+
const coef = (i) => (i === 0 ? '1' : `g^${i}`);
53+
54+
/**
55+
* The content of every cell of a placement, row by row: a Map segment → coefficient,
56+
* or null for a cell that holds nothing (parity over an empty stripe).
57+
* @param {Placement} placement
58+
* @returns {(Map<number, string> | null)[][]}
59+
*/
60+
function contents(placement) {
61+
if (!('stripes' in placement)) return [];
62+
return placement.stripes.map((row) => {
63+
// The data segments of THIS stripe, in segment order — what P sums and Q weights.
64+
const segs = row.filter((c) => c.role === 'data' && c.seg !== null)
65+
.map((c) => /** @type {number} */ (c.seg)).sort((a, b) => a - b);
66+
return row.map((cell) => {
67+
if (cell.role === 'data' || cell.role === 'mirror')
68+
return cell.seg === null ? null : new Map([[cell.seg, '1']]);
69+
if (segs.length === 0) return null; // parity of nothing
70+
const m = new Map();
71+
segs.forEach((s, i) => m.set(s, cell.role === 'Q' ? coef(i) : '1'));
72+
return m;
73+
});
74+
});
75+
}
76+
77+
/** Canonical string of a content, so equal contents compare equal. */
78+
const key = (content) =>
79+
[...content.entries()].sort((a, b) => a[0] - b[0]).map(([s, c]) => `${s}:${c}`).join(',');
80+
81+
const isSingleBlock = (content) => content.size === 1 && [...content.values()][0] === '1';
82+
83+
// ---------------------------------------------------------------------------
84+
// BEHAVIOUR — what the contents say the array does, with no names
85+
// ---------------------------------------------------------------------------
86+
87+
/**
88+
* @param {ArrayNode} node a LEAF array (members are disks); nested nodes are
89+
* out of scope — their collapse composes structurally (§3)
90+
* @param {{ stripes?: number, chunks?: number }} [opts]
91+
* @returns {{ disks: number, segments: number, copies: { min: number, max: number },
92+
* parityPerStripe: number, rows: number,
93+
* shape: { segmentation: Segmentation, redundancy: Redundancy } | null,
94+
* note: string | null }}
95+
*/
96+
function behaviour(node, opts = {}) {
97+
const placement = Layout.computePlacement(node, opts);
98+
const n = node.members.length;
99+
if (!('stripes' in placement))
100+
return { disks: n, segments: 0, copies: { min: 0, max: 0 }, parityPerStripe: 0, rows: 0,
101+
shape: null, note: placement.reason };
102+
103+
const grid = contents(placement);
104+
105+
// copies(s): the DISTINCT disks holding a cell whose content is exactly {s: 1}.
106+
/** @type {Map<number, Set<number>>} */
107+
const holders = new Map();
108+
let parityPerStripe = 0;
109+
grid.forEach((row) => {
110+
let parityHere = 0;
111+
row.forEach((content, disk) => {
112+
if (!content) return;
113+
if (isSingleBlock(content)) {
114+
const s = [...content.keys()][0];
115+
if (!holders.has(s)) holders.set(s, new Set());
116+
holders.get(s).add(disk);
117+
} else {
118+
parityHere++;
119+
}
120+
});
121+
parityPerStripe = Math.max(parityPerStripe, parityHere);
122+
});
123+
124+
const segments = holders.size;
125+
if (segments === 0)
126+
return { disks: n, segments, copies: { min: 0, max: 0 }, parityPerStripe, rows: grid.length,
127+
shape: null, note: 'holds no data — every cell is parity over an empty stripe' };
128+
129+
const counts = [...holders.values()].map((set) => set.size);
130+
const copies = { min: Math.min(...counts), max: Math.max(...counts) };
131+
132+
/** @type {{ segmentation: Segmentation, redundancy: Redundancy } | null} */
133+
let shape = null;
134+
let note = null;
135+
if (parityPerStripe >= 3) {
136+
note = 'three or more parity terms per stripe — no shape in the two-axis model';
137+
} else if (parityPerStripe > 0) {
138+
shape = { segmentation: 'striped', redundancy: parityPerStripe === 1 ? 'parity1' : 'parity2' };
139+
} else if (copies.min >= 2) {
140+
// Every disk holds every segment → a plain mirror, whatever the drawn rows say
141+
// (far puts the copies in other rows; by content it is a mirror all the same).
142+
shape = copies.min === n
143+
? { segmentation: 'linear', redundancy: 'mirror' }
144+
: { segmentation: 'striped', redundancy: 'mirror' };
145+
} else {
146+
// No redundancy at all. Striped vs linear is the one distinction the contents
147+
// cannot make (a segment is a segment); it is read off the placement's rows —
148+
// a concatenation is drawn as a single row, one segment per disk.
149+
shape = grid.length > 1 || n === 1
150+
? { segmentation: 'striped', redundancy: 'none' }
151+
: { segmentation: 'linear', redundancy: 'none' };
152+
}
153+
return { disks: n, segments, copies, parityPerStripe, rows: grid.length, shape, note };
154+
}
155+
156+
// ---------------------------------------------------------------------------
157+
// EXPORT
158+
// ---------------------------------------------------------------------------
159+
160+
const RaidContent = { contents, behaviour, key, isSingleBlock };
161+
162+
if (typeof module !== 'undefined' && module.exports) module.exports = RaidContent;
163+
else root.RaidContent = RaidContent;
164+
165+
})(typeof globalThis !== 'undefined' ? globalThis : this);

src/engine/levels.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
// them for the real files) and forbidden on a nested one: the collapse
101101
// composes from the spans (spec §3), so a rule on the outer level would be a
102102
// second statement of the same fact, free to drift.
103-
const COLLAPSE_KEYS = ['minDisksToRun', 'minDisksToRunSource', 'collapsesTo'];
103+
const COLLAPSE_KEYS = ['minDisksToRun', 'minDisksToRunSource', 'collapsesTo', 'absorbsNested'];
104104

105105
function validateCollapses(def) {
106106
if (def.shape.members !== 'disks') {
@@ -117,6 +117,12 @@
117117
} else if (def.minDisksToRunSource !== undefined) {
118118
fail(`${def.id}: minDisksToRunSource without a minDisksToRun`);
119119
}
120+
if (def.absorbsNested !== undefined) {
121+
const a = def.absorbsNested;
122+
if (!a || typeof a !== 'object') fail(`${def.id}: absorbsNested must be { because, source }`);
123+
if (typeof a.because !== 'string' || !a.because) fail(`${def.id}: absorbsNested.because is required (the player-facing sentence)`);
124+
if (typeof a.source !== 'string' || !a.source) fail(`${def.id}: absorbsNested.source is required (the algebra, or the kernel line)`);
125+
}
120126
if (def.collapsesTo === undefined) return;
121127
if (!Array.isArray(def.collapsesTo)) fail(`${def.id}: collapsesTo must be a list`);
122128
const widths = new Set();

0 commit comments

Comments
 (0)