diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f3a3ab5..8026ef4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ reverse. ## 2026-08-24 +- An optional node (`+`) joins the cheapest path while it is being worked on — started work is the open front +- Fix: a started alternative lost its `|`/`=` group to an untouched cheaper one, although the choice was already made - A node written as an id alone (`- #US-123`) is now titled by that id, instead of being ignored - A line ending in a space and `\` continues on the next line, so a long node no longer has to fit into one - A star in the header opens What's new: the changes of the last few days, and a link per day that shows the nodes it touched in the diagram diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ea7c094..70d0610 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4319,3 +4319,100 @@ Knoten. `#`-Umschalter bekommt er **keine** `nid`-Spanne, der Nachbar `#auth: Backend` schon. 337 Tests, davon 6 neue; der eine alte, der die frühere Regel festhielt, ist umgeschrieben und benennt jetzt diese. + +## D61 — Angefangenes liegt auf dem Pfad: Zugaben und Alternativen +Gemeldet an einem kleinen Baum: + +``` +- [ ] #fe + + [~] #fe.rel: Relations bearbeitbar (S) + + [?] #fe.more: weitere Features … +``` + +Erwartet war, dass der Pfad durch `#fe.rel` läuft — „auch wenn es optional +ist, weil das ja nun schon begonnen wurde". Er lief stattdessen durch `#fe`, +und die einzige Station war der Elternknoten. + +**Die Regel dahinter stand seit D29 ohne Ausnahme da:** Optionale Knoten sind +*nie* nötig. Sie ist aus einem echten Fehler entstanden — der Pfad rechnete +jede Zugabe ins Minimum und **überschätzte** sich systematisch. Der umgekehrte +Fehler war dabei nie bedacht: angefangene Arbeit zu **unterschlagen**. Seit +D46 beantwortet der Pfad ohnehin nicht mehr „was hätte der Plan von vorn +gekostet?", sondern „was ist als Nächstes dran?" — und `[~]` ist das +Vorderste, was es gibt. Praktisch heißt das, dass der +Von-Station-zu-Station-Knopf (D47) einen nie dorthin führt, wo gerade +tatsächlich gearbeitet wird. + +`+` ist eine Aussage über den **Plan** („entbehrlich"), der Status eine über +die **Tatsachen** („daran wird gearbeitet"). Die beiden Achsen bleiben +unabhängig (§3) — der Pfad ist ein Drittes und darf beide lesen. + +**Entschieden: Eine Zugabe liegt auf dem Pfad, sobald sie realisiert (§3), +aber noch nicht erledigt (D46) ist** — also bei `[~]` und `[/]`. + +**Die Formulierung ist der eigentliche Fund.** Mein erster Vorschlag war die +volle Schwelle „realisiert" (`[~] [/] [x] [^]`), begründet damit, dass §3 das +Wort schon führt und keine dritte Schwelle dazukommt. Der Nutzer hat +widersprochen: *„Eigentlich sollen fertige oder gar deployed Knoten gar nicht +auf den Lean-Path, da gibt es ja nichts mehr zu tun."* Das ist richtig, und +mein Argument gegen die engere Fassung fällt weg, sobald man sie aus den +**vorhandenen** Begriffen zusammensetzt: *realisiert, aber nicht erledigt*. +`isStarted = isRealized && !isDone` — kein neues Vokabular. + +**Was dabei zu klären war: „auf dem Pfad" ist nicht „Station".** Seit D46 +bekommt ein erledigter Knoten auf dem Pfad **keinen** Stationspunkt, **keine** +Pfadlinie und geht mit **0** in die Kosten; übrig bleibt allein, dass er nicht +zurücktritt — und das tut er per D46-Nachtrag ohnehin nie. Für den fertigen +Knoten **selbst** war der Unterschied zwischen beiden Schwellen also exakt +null. Er lag einzig darin, ob der Pfad **in eine fertige Zugabe hineinschaut** +und dort liegen gebliebene offene Kinder als Station zeigt. Auch das entfällt +jetzt, und zwar mit einem eigenen Argument aus §3: Wer unter einem `+`-Knoten +hängt, ist mit ihm zusammen entbehrlich — der Autor hat `[x]` geschrieben, ein +offener Rest darunter ist Buchhaltung, keine offene Front. + +**Ehrlich zu benennen ist der Einwand gegen die ganze Regel:** Streng „am +günstigsten" wäre es, die angefangene Zugabe **abzubrechen** — Restkosten +gespart. Der Pfad zeigt seit D46 aber die offene Front, nicht das theoretische +Optimum. Und der Rückweg steht in der Notation schon: Wer die Zugabe wirklich +fallen lässt, schreibt `[-]`, und Verworfenes zählt nie. + +**Dabei gefunden: dieselbe Lücke bei Alternativen — und die SPEC hatte recht, +der Code nicht.** §9 sagt seit D46 wörtlich: „Eine bereits realisierte +Alternative gewinnt, auch wenn eine unangetastete nominell billiger wäre — die +Wahl ist getroffen und bezahlt." Umgesetzt war das aber nur **über die +Kosten**, und die sind allein bei `[x]`/`[^]` null. Nachgemessen an +`= [~] A (L)` / `= [ ] B (S)`: Der Pfad wählte **B** und blasste das +angefangene A aus — das Bild widersprach damit der XOR-Regel des Plans, die +gerade sagt, dass A die realisierte Alternative ist. Also kein neues Feature, +sondern die fehlende Hälfte der Umsetzung: `chosenPool(kids)` schränkt die +Wahlmenge auf die realisierten Alternativen ein, sobald es welche gibt; die +Kostenregel (kleinste rekursive Kosten, Gleichstand ⇒ erste) gilt darin +unverändert. + +**Mehrere realisierte Alternativen** sind in einer `=`-Gruppe schon per +`xorConflict` gemeldet, in einer `|`-Gruppe („mindestens eine") aber zulässig. +Dort entscheiden unter ihnen wieder die Kosten. Erwogen und **aufgeschoben**: +alle realisierten gemeinsam auf den Pfad zu nehmen. Das wäre ein größerer +Eingriff — eine any-of-Gruppe trüge dann nicht mehr genau eine Alternative —, +und die gewählte Lesart ist für sich verteidigbar: `|` erlaubt das Fallenlassen, +also ist „die billigere der begonnenen fertigstellen" ein gültiger günstigster +Weg. + +**Nebengewinn bei der Suche (D42):** Eine Gruppe mit genau einer realisierten +Alternative ist entschieden und damit **keine freie Variable** mehr — sie +koppelt nicht und geht nicht in den Odometer ein. Die erschöpfende Suche wird +dadurch kleiner, nie größer. + +**Nachgemessen.** Der gemeldete Baum: `#fe.rel` ist jetzt die Station, `#fe` +liegt auf dem Pfad ohne Punkt, `#fe.more` bleibt blass. Eine fertige Zugabe +mit offenem Kind bleibt samt Kind draußen. `= [~] A (L)` schlägt +`= [ ] B (S)`. Der **mitgelieferte Plan ändert sich nicht** (131 Pfadknoten, +27 Stationen, vorher wie nachher) — er hat keine angefangene Zugabe, und seine +einzige Gruppe mit realisierten Alternativen trägt zwei `[^]`, die schon +vorher beide 0 kosteten. + +346 Tests, davon 10 neue. Gegenprobe: Nimmt man die Zugaben-Ausnahme wieder +heraus, fallen genau die fünf danach benannten Zusicherungen; nimmt man +`chosenPool` heraus, genau die zwei zu den Alternativen. Die Tests, die das +**unveränderte** Verhalten festhalten (unangetastete und erledigte Zugaben +bleiben draußen), bleiben in beiden Fällen grün. diff --git a/docs/SPEC.md b/docs/SPEC.md index 2376d7d..5267273 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -545,10 +545,21 @@ werden die für die günstigste Realisierung **nötigen** Knoten: - **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe plus — je Gate — Summe bzw. Minimum der Kinder). Bei Gleichstand gewinnt die **erste** Alternative. -- **Optionale Knoten (`+`, §3) sind nie nötig** — sie zählen weder zu den - Kosten ihres Elternknotens noch liegen sie auf dem Pfad, und der Teilbaum - unter ihnen ebenso wenig. Genau dafür gibt es das Zeichen: Ohne `+` rechnet - der günstigste Pfad jede Zugabe ins Minimum ein und überschätzt es. + **Ist in der Gruppe etwas realisiert** (§3: `[~]`, `[/]`, `[x]`, `[^]`), ist + die Wahl damit getroffen — gewählt wird nur noch unter den realisierten + Alternativen, auch wenn eine unangetastete nominell billiger wäre. Sind es + mehrere (in einer `=`-Gruppe schon per `xorConflict` gemeldet, in einer + `|`-Gruppe zulässig), entscheidet unter ihnen wieder die Kostenregel. +- **Optionale Knoten (`+`, §3) sind nur nötig, solange an ihnen gearbeitet + wird** — also wenn sie realisiert (§3), aber noch nicht erledigt sind: + `[~]` und `[/]`. Sonst zählen sie weder zu den Kosten ihres Elternknotens + noch liegen sie auf dem Pfad, und der Teilbaum unter ihnen ebenso wenig. + Genau dafür gibt es das Zeichen: Ohne `+` rechnet der günstigste Pfad jede + Zugabe ins Minimum ein und überschätzt es. Die Ausnahme hält den umgekehrten + Fehler heraus — eine angefangene Zugabe ist offene Arbeit, und der Pfad + zeigt die offene Front. **Erledigte Zugaben bleiben draußen:** Dort ist + nichts mehr zu tun, und was darunter offen blieb, ist mit ihnen zusammen + entbehrlich (§3). Siehe D61. - Verworfene Knoten zählen nie mit (unabhängig vom „verworfene einblenden"- Filter). - **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 1ce96a4..6b86851 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -43,6 +43,7 @@ - [^] #ed.path: Cheapest path (M) - [^] #ed.path.cost: Cost model, missing size counts as M (S) - [^] #ed.path.front: Only the open front: what is done costs nothing (S) %% see D46 + - [x] #ed.path.started: Started work is on the path, extras included (S) %% see D61 - [^] #ed.path.line: Metro-map line through the leaves (S) - [^] #ed.path.step: Step from station to station, one button (S) %% see D47 - [^] #ed.closure: Count the whole dependency closure (M) @@ -385,6 +386,11 @@ what the plan would have cost from scratch. An alternative already built therefore wins its group even against a cheaper untouched one. +#ed.path.started + An extra (`+`) joins the path while it is being worked on, and a started + alternative wins its group. Optionality says the plan can do without it; + the status says work has begun, and the front is made of exactly that. + #ed.path.line A dashed curve threads through the open leaves of the path, with a pale station dot at each. Borrowed from metro maps: the dot is large and faint so diff --git a/frontend/public/llms.md b/frontend/public/llms.md index 88a940b..b7963dc 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -63,7 +63,8 @@ One node per line. Everything except the label is optional. `[~]`, `[/]`, `[x]` or `[^]`); each additional realized one warns (`xorConflict`). - Optionality (`+`) is orthogonal to status: an optional node can be long - `[^]`. Optional nodes never count toward the cheapest path. + `[^]`. An optional node counts toward the cheapest path **only while it is + being worked on** — `[~]` or `[/]`; untouched and finished ones stay out. ### Fold mark (immediately before the label, i.e. after the status box) @@ -96,10 +97,11 @@ One node per line. Everything except the label is optional. - **`[x]` and `[^]` cost nothing any more.** The cheapest path prices the work that is *left*, so a done node adds zero regardless of its size and carries no station on the path line; started work (`[~]`, `[/]`) still - counts in full. A realized alternative therefore wins its `|`/`=` group - even when a cheaper unstarted one sits next to it. The *intrinsic* status - decides — dependencies may hold a node back effectively, but the work on - it is paid for. + counts in full. **A realized alternative wins its `|`/`=` group** even when + a cheaper unstarted one sits next to it — the choice has been made; among + several realized ones cost decides again. The *intrinsic* status decides — + dependencies may hold a node back effectively, but the work on it is paid + for. ### Size (effort) diff --git a/frontend/src/model.js b/frontend/src/model.js index 1458a68..2c4295e 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -40,14 +40,18 @@ export function visibleChildren(n, showDiscarded){ any-of und XOR ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende - Größe = M. + Größe = M. Ist in einer disjunktiven Gruppe etwas realisiert, wird nur noch + unter den realisierten gewählt (`chosenPool`, D61). Optionale Kinder (`+`, SPEC §3/D29) fallen hier ebenfalls heraus — sie sind - per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da beide - Nutzer (`cheapestCost`, `markCheapest`) über diese Funktion gehen, gilt das - samt Teilbaum. */ + per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da alle + Nutzer über diese Funktion gehen, gilt das samt Teilbaum. + AUSNAHME (D61): Wird an der Zugabe gerade gearbeitet (`isStarted`), liegt + sie auf dem Pfad — angefangene Arbeit IST die offene Front. Erledigte + Zugaben bleiben draußen: Dort ist nichts mehr zu tun, und was darunter + offen blieb, ist mit ihnen zusammen entbehrlich (§3). */ export function pathChildren(n){ return n.children.filter(k => - !k.optional && (!k.status || k.status.key !== 'verworfen')); + (!k.optional || isStarted(k)) && (!k.status || k.status.key !== 'verworfen')); } /* ---------- Erledigt: was nichts mehr kostet (SPEC §9, D46) ---------- `[x]` fertig und `[^]` in Produktion. Die Beförderung auf Prod ist keine @@ -60,13 +64,29 @@ export function pathChildren(n){ export function isDone(n){ return !!n.status && (n.status.key === 'fertig' || n.status.key === 'prod'); } +/* „realisiert" (SPEC §3, D35): Kosten investiert oder mehr. Trägt die + XOR-Regel und — seit D61 — die Wahl in Alternativgruppen. */ +export function isRealized(n){ + return !!n.status && ['arbeit', 'durchstich', 'fertig', 'prod'].includes(n.status.key); +} +/* Angefangen und noch offen: realisiert, aber nicht erledigt (D61). Aus zwei + vorhandenen Begriffen zusammengesetzt statt einer dritten Schwelle. */ +export function isStarted(n){ return isRealized(n) && !isDone(n); } +/* Wahlmenge einer disjunktiven Gruppe (SPEC §9, D61): Ist etwas realisiert, + ist die Wahl getroffen — dann wird nur noch unter den realisierten gewählt. + Sonst stehen alle zur Wahl. Die Kostenregel (kleinste rekursive Kosten, + Gleichstand ⇒ erste) gilt innerhalb der Menge unverändert. */ +export function chosenPool(kids){ + const real = kids.filter(isRealized); + return real.length ? real : kids; +} /* fehlende Größe wird als M interpretiert; Erledigtes kostet nichts mehr */ export function ownCost(n){ return isDone(n) ? 0 : SIZE_RANK[n.size || 'M'] + 1; } export function cheapestCost(n){ const kids = pathChildren(n); let c = ownCost(n); if(kids.length){ - if(gateOf(kids) !== 'and') c += Math.min(...kids.map(cheapestCost)); + if(gateOf(kids) !== 'and') c += Math.min(...chosenPool(kids).map(cheapestCost)); else c += kids.reduce((s, k) => s + cheapestCost(k), 0); } return c; @@ -129,10 +149,14 @@ export function computeCheapPlan(roots){ } })(roots); for(const kids of groups){ - let best = kids[0], bc = cheapestCost(kids[0]); - for(const k of kids.slice(1)){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } + const pool = chosenPool(kids); + let best = pool[0], bc = cheapestCost(pool[0]); + for(const k of pool.slice(1)){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } localChoice.set(kids[0], best); /* Schlüssel: erstes Kind der Gruppe */ - if(anyDeps && kids.some(k => touches.get(k))) coupled.push(kids); + /* Eine entschiedene Gruppe (genau eine realisierte Alternative, D61) ist + keine freie Variable mehr — sie koppelt nicht und verkleinert die Suche. */ + if(anyDeps && pool.length > 1 && pool.some(k => touches.get(k))) + coupled.push({key: kids[0], pool}); } /* Nötige Menge für eine Belegung der gekoppelten Gruppen. */ @@ -159,7 +183,7 @@ export function computeCheapPlan(roots){ const costOf = set => { let c = 0; set.forEach(n => c += ownCost(n)); return c; }; let product = 1; - for(const kids of coupled){ product *= kids.length; if(product > EXACT_LIMIT) break; } + for(const grp of coupled){ product *= grp.pool.length; if(product > EXACT_LIMIT) break; } if(product > EXACT_LIMIT){ /* Gierig, aber benannt (D42): lokale Wahl überall, Hülle trotzdem. */ return {set: needed(new Map()), exact: false}; @@ -171,12 +195,12 @@ export function computeCheapPlan(roots){ let best = null, bc = Infinity; for(;;){ const choice = new Map(); - coupled.forEach((kids, g) => choice.set(kids[0], kids[idx[g]])); + coupled.forEach((grp, i) => choice.set(grp.key, grp.pool[idx[i]])); const set = needed(choice); const c = costOf(set); if(c < bc){ bc = c; best = set; } let g = coupled.length - 1; - while(g >= 0 && ++idx[g] >= coupled[g].length){ idx[g] = 0; g--; } + while(g >= 0 && ++idx[g] >= coupled[g].pool.length){ idx[g] = 0; g--; } if(g < 0) break; } return {set: best, exact: true}; diff --git a/frontend/tests/frontier.test.js b/frontend/tests/frontier.test.js index 2ddabe5..c1925c6 100644 --- a/frontend/tests/frontier.test.js +++ b/frontend/tests/frontier.test.js @@ -80,10 +80,22 @@ describe('Auswahl — die getroffene Wahl gewinnt', () => { | [ ] Billig (XS)`)).toEqual(['Gemacht', 'Wahl']); }); - it('eine erst angefangene Alternative gewinnt dadurch NICHT', () => { + /* Bis D61 gewann hier „Billig": Die Wahl lief allein über die Kosten, und + die sind nur bei `[x]`/`[^]` null. Angefangenes kostet voll und verlor + damit — obwohl SPEC §9 die Regel schon so formuliert hatte. */ + it('gilt auch für eine erst angefangene Alternative', () => { expect(cheapLabels(`[ ] Wahl (XS) | [~] Angefangen (L) - | [ ] Billig (XS)`)).toEqual(['Billig', 'Wahl']); + | [ ] Billig (XS)`)).toEqual(['Angefangen', 'Wahl']); + }); + + /* Die unangetastete ist hier die billigste von allen — gewählt wird + trotzdem unter den realisierten, und dort entscheiden die Kosten. */ + it('lässt unter mehreren realisierten wieder die Kosten entscheiden', () => { + expect(cheapLabels(`[ ] Wahl (XS) + | [~] Teuer (L) + | [/] Günstig (S) + | [ ] Unangetastet (XS)`)).toEqual(['Günstig', 'Wahl']); }); it('gilt auch in einer XOR-Gruppe', () => { diff --git a/frontend/tests/optional.test.js b/frontend/tests/optional.test.js index bb5a7ba..ef6f15d 100644 --- a/frontend/tests/optional.test.js +++ b/frontend/tests/optional.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; -import { computeCheapSet, cheapestCost, visibleChildren } from '../src/model.js'; +import { computeCheapSet, cheapestCost, cheapCls, visibleChildren } from '../src/model.js'; import { renderTreeHtml } from '../src/render.js'; const t = key => key; @@ -38,7 +38,7 @@ describe('Parser — `+` setzt optional, nicht das Gate', () => { }); }); -describe('Günstigster Pfad — optionale Knoten sind nie nötig', () => { +describe('Günstigster Pfad — unangetastete Zugaben sind nicht nötig', () => { const BAUM = `[ ] Wurzel (S) - [ ] Pflicht (S) + [ ] Zugabe (XXL) @@ -69,6 +69,59 @@ describe('Günstigster Pfad — optionale Knoten sind nie nötig', () => { }); }); +/* Die Ausnahme (D61): Wird an der Zugabe gearbeitet, ist sie offene Arbeit — + und der Pfad zeigt seit D46 die offene Front. Erledigte bleiben draußen. */ +describe('Günstigster Pfad — an einer angefangenen Zugabe wird gearbeitet', () => { + it('nimmt eine `[~]`-Zugabe auf den Pfad', () => { + expect(cheapLabels(`[ ] Wurzel (S)\n - [ ] Pflicht (S)\n + [~] Zugabe (S)`)) + .toEqual(['Pflicht', 'Wurzel', 'Zugabe']); + }); + + it('nimmt eine `[/]`-Zugabe ebenso', () => { + expect(cheapLabels(`[ ] Wurzel (S)\n + [/] Zugabe (S)`)) + .toEqual(['Wurzel', 'Zugabe']); + }); + + it('lässt `[?]`, `[ ]`, `[!]` und ohne Status weiterhin draußen', () => { + expect(cheapLabels(`[ ] Wurzel (S) + + [?] Idee (S) + + [ ] Geplant (S) + + [!] Riskant (S) + + Neutral (S)`)).toEqual(['Wurzel']); + }); + + /* Genau der Punkt, an dem die Empfehlung korrigiert wurde: Fertiges gehört + nicht auf die offene Front — und was darunter offen blieb, ist mit der + Zugabe zusammen entbehrlich (§3). */ + it('lässt eine erledigte Zugabe samt offenem Rest draußen', () => { + expect(cheapLabels(`[ ] Wurzel (S) + + [x] Fertige Zugabe (S) + - [ ] Rest (M) + + [^] Live (S)`)).toEqual(['Wurzel']); + }); + + it('nimmt den Teilbaum der angefangenen Zugabe mit', () => { + expect(cheapLabels(`[ ] Wurzel (S)\n + [~] Zugabe (S)\n - [ ] Teil (S)`)) + .toEqual(['Teil', 'Wurzel', 'Zugabe']); + }); + + it('rechnet sie damit auch in die Kosten des Elternknotens', () => { + /* Wurzel (S=2) + Zugabe (S=2) = 4; unangetastet wären es 2. */ + expect(cheapestCost(roots(`[ ] Wurzel (S)\n + [~] Zugabe (S)`)[0])).toBe(4); + }); + + it('macht sie zur Station statt des Elternknotens', () => { + const [wurzel] = roots(`[ ] Wurzel (S)\n + [~] Zugabe (S)`); + const set = computeCheapSet([wurzel]); + expect(cheapCls(wurzel, set, false)).toBe('cheap'); + expect(cheapCls(wurzel.children[0], set, false)).toBe('cheap cheap-leaf'); + }); + + it('lässt eine verworfene Zugabe auch angefangen draußen', () => { + expect(cheapLabels(`[ ] Wurzel (S)\n + [-] Abgebrochen (S)`)).toEqual(['Wurzel']); + }); +}); + describe('Renderer — Kennzeichnung und Gemischt-Warnung', () => { it('gibt dem optionalen Knoten die Klasse `opt`', () => { const {html} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);