diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 229f2b6..4a8ac93 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-25 +- A size prices its whole subtree on the cheapest path — sub-packages no longer add on top, so a carefully decomposed `(S)` beats a coarse `(L)` - The size is now the last free-standing `(L)`-style token of the line — earlier ones stay in the title, and `"(L)"` or `((L))` mention a size literally - Fix: Ctrl+click on a dependency preceded by a non-breaking space did nothing, although the parser reads it as a dependency - Ctrl+click a dependency `:#id` in the text jumps to the line that defines the ID — Ctrl+Enter does the same at the caret diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c82e8b..7715202 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5098,3 +5098,65 @@ selben Zug (Schritt-3-Regel, Größen-Abschnitt, Zitier-Faustregel). per Mutation: erster statt letzter Treffer → genau der Letztes-gewinnt-Test fällt; Anker entfernt → genau die zwei Alleinstehend-/Zitier-Tests. Alle Snapshots (kanonisches Beispiel §10) unverändert. + +## D69 — Die Größe bepreist den ganzen Teilbaum: Kinder kommen nicht obendrauf +Gemeldet am eigenen Beispiel: Eine `|`-Gruppe mit `Manuell mit Downtime (S)` +(zerlegt in XS + S + eine Zugabe) verlor gegen `Failover (L)` — der Pfad +rechnete S(2) + XS(1) + S(2) = 5 gegen L(4). Der Einwand des Nutzers trifft +das Modell im Kern: **„Manuell mit Downtime" ist mit (S) bepreist — dann ist +egal, wie teuer die Summe der Teilpakete ist.** Ob die Zerlegung in die +Größe passt, prüft seit D62 der Größen-Konflikt; die Pfadrechnung hat die +Bewertung nicht anzuzweifeln. Das alte Modell (D18: „eigene Größe plus +Summe/Minimum der Kinder") zählte doppelt und bestrafte damit systematisch +genau die Pläne, die sorgfältig zerlegen — je ehrlicher die Zerlegung, desto +teurer sah das Paket aus. + +**Neue Regel: Der Preis eines Knotens ist seine Größe.** Angegeben oder — +seit D66 — aus den Teilpaketen geschätzt; die Schätzung ist dieselbe Sorte +Gesamtaussage und übernimmt die Rolle nahtlos. Erledigtes bleibt 0 (D46). +`cheapestCost()` kollabiert damit auf `ownCost()`: Die Rekursion über die +Kinder entfällt, die Aggregation für größenlose Knoten trägt allein die +D66-Schätzregel. Die Wahl in einer Alternativgruppe vergleicht schlicht die +Größen der Alternativen. + +**Die Bewertung gilt auch, wenn die Kinder sie sprengen.** Vier `(S)` unter +einem `(S)` sind ein `sizeConflict` — der Marker zeigt es, aber der Knoten +bleibt bewertet, wie er bewertet wurde (ausdrückliche Nutzer-Entscheidung: +„der übergeordnete Knoten bleibt dennoch S"). Die Alternative — die Kinder +könnten den Preis anheben (max-Variante) — war vorgeschlagen und ist damit +verworfen: Sie hätte die Bewertung des Autors stillschweigend überstimmt, +und für den Widerspruch gibt es bereits den lauten Kanal. + +**Das Vereinigungs-Maß der Closure-Suche (D42) zieht mit.** Die Suche über +die gekoppelten Gruppen verglich Belegungen über die Summe der Knoten-Preise +der nötigen Menge — mit Teilbaum-Bepreisung zählte ein zerlegtes +Abhängigkeits-Ziel dann mehrfach (Eltern plus Kinder), ein grobes nur +einmal: dieselbe Zerlegungs-Strafe, nur im Suchmaß. Jeder nötige Knoten +zählt jetzt nur mit dem, was seine Größe **über die nötigen Teilpakete +hinaus** behauptet (nie negativ); die Summe dieser Margen bepreist einen +vollständig zerlegten Teilbaum mit seiner Spitzengröße, gemeinsam +Gebrauchtes zählt über die Mengen-Vereinigung weiterhin einmal. Benannte +Grenze: Wo Kinder ihre Elterngröße im Rangraum übersteigen, liegt das +Suchmaß über dem Preis der lokalen Wahl — die beiden Maße sind dann nicht +identisch; das betrifft genau die Teilbäume, die der Größen-Konflikt ohnehin +anmahnt oder die eng bepreist sind, und die lokale Wahl (der Regelfall) +folgt strikt der Nutzer-Regel. + +**Benannter Verlust:** Erledigte Teilpakete unter einem offenen Knoten +senken dessen Preis nicht mehr (kein Kinder-Summieren, keine Bruchteile — +D46 lehnte anteilige Restkosten schon ab). Für die Wahl trägt das kaum: +Sobald an einer Alternative selbst gearbeitet ist, entscheidet ohnehin +`chosenPool` (D61); Stationen und Markierung behalten ihre Knoten-Genauigkeit +unverändert. + +**Nachgemessen:** Der gemeldete Fall wählt jetzt „Manuell mit Downtime" +(S=2 gegen L=4). Der mitgelieferte Werkbaum-Plan wählt unter altem und neuem +Modell **identisch** (137 Pfadknoten, 57 Stationen, gleiche Menge — per +git-stash-Vergleich gemessen, nicht angenommen). 450 Tests, davon 5 neue in +`tests/pricing.test.js`; drei Alt-Tests, die die Summen-Semantik festhielten, +sind auf die neue Regel umgeschrieben. Gegenproben per Mutation: alte +Rekursion zurückgebaut → genau die drei danach benannten Zusicherungen +fallen; Vereinigungs-Maß auf schlichte Summe zurück → genau der eine +Marginal-Test (dessen erste Fassung nicht unterschied, weil Wurzeln immer +nötig sind — der Testbaum musste die Ziele unter einen unangetasteten +`+`-Zweig legen). diff --git a/docs/SPEC.md b/docs/SPEC.md index cccdc42..24c0d8f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -598,18 +598,28 @@ wird die **noch offene** Arbeit — der Pfad beantwortet „was ist als Nächste am günstigsten?", nicht „was hätte der Plan von vorn gekostet?". Ermittelt werden die für die günstigste Realisierung **nötigen** Knoten: +- **Die Größe bepreist den ganzen Teilbaum.** Der Preis eines Knotens ist + seine **angegebene Größe** (§5) — die Teilpakete kommen **nicht** noch + einmal obendrauf: Wer ein Paket mit `(S)` bewertet hat, hat den Teilbaum + bewertet. Ob die Zerlegung in die Größe passt, prüft der Größen-Konflikt + (§5) — die Pfadrechnung zweifelt die Bewertung nicht an. Fehlt die Größe, + vertritt die Schätzung aus den Teilpaketen sie (unten). Siehe D69 (bis + dahin galt: eigene Größe **plus** Summe/Minimum der Kinder — das + bestrafte gerade die sorgfältig zerlegten Pakete). - **Erledigtes kostet nichts mehr:** Ein Knoten mit `[x]` oder `[^]` (§4) geht mit **0** in die Rechnung, unabhängig von seiner Größe. Angefangenes (`[~]`, `[/]`) zählt dagegen **voll** — die Arbeit ist noch offen. Maßgeblich ist der **intrinsische** Status: Investiert ist investiert, auch wenn Abhängigkeiten - den Knoten effektiv zurückhalten (§4). Abgezogen werden nur die **eigenen** - Kosten des Knotens, nicht die seines Teilbaums. Folge in Alternativgruppen: - Eine bereits realisierte Alternative gewinnt, auch wenn eine unangetastete - nominell billiger wäre — die Wahl ist getroffen und bezahlt. Siehe D46. + den Knoten effektiv zurückhalten (§4). Offene Teilpakete unter einem + erledigten Knoten bleiben auf dem Pfad und behalten ihre Stationen — die + Kostenfrage stellt sich dort nicht mehr, die Wahl ist realisiert. Folge in + Alternativgruppen: Eine bereits realisierte Alternative gewinnt, auch wenn + eine unangetastete nominell billiger wäre — die Wahl ist getroffen und + bezahlt. Siehe D46. - **all of:** alle Kinder sind nötig. -- **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. +- **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" ist die + **Größe der Alternative selbst** — angegeben oder geschätzt (oben/unten). + Bei Gleichstand gewinnt die **erste** Alternative. **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 @@ -617,10 +627,10 @@ werden die für die günstigste Realisierung **nötigen** Knoten: `|`-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 + `[~]` und `[/]`. Sonst liegen sie nicht auf dem Pfad (der Teilbaum unter + ihnen ebenso wenig) und zählen nicht zur Größen-Schätzung (unten). + Genau dafür gibt es das Zeichen: Ohne `+` läge jede + Zugabe auf dem günstigsten Pfad. 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 @@ -654,7 +664,11 @@ Gebrauchtes zählt über die Mengen-Vereinigung nur **einmal**. Regeln: bezahlt werden. **Verfahren (D42): erschöpfende Suche** über die **gekoppelten** Gruppen (Gruppen, deren Teilbäume Abhängigkeiten enthalten oder gebraucht werden); alle übrigen wählen lokal wie bisher, bei - Gleichstand die erste. Wird die Suche zu groß, rechnet die Anzeige **gierig + Gleichstand die erste. Verglichen werden die Belegungen über die Summe + dessen, was jeder nötige Knoten **über seine nötigen Teilpakete hinaus** + behauptet (nie negativ) — so bepreist auch die Vereinigung einen Teilbaum + mit seiner Größe, statt Zerlegungstiefe zu bestrafen (D69). Wird die Suche + zu groß, rechnet die Anzeige **gierig und sagt es** — Warnung `cheapApprox`, zeilenlos. Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 6c324bd..0a950b5 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -43,7 +43,7 @@ - [^] #ed.deplink.calm: Visually secondary — the tree carries the message (S) - [^] #ed.deplink.focus: Highlight what a selected node needs and feeds (S) - [^] #ed.path: Cheapest path (XL) - - [^] #ed.path.cost: Cost model, sizes summed and minimized (S) + - [x] #ed.path.cost: Cost model, a size prices its subtree (S) - [^] #ed.path.est: Missing size estimated from the sub-packages (S) %% see D66 - [^] #ed.path.front: Only the open front: what is done costs nothing (S) %% see D46 - [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61 @@ -394,8 +394,9 @@ the cheapest alternative. This is the feature the whole notation exists for. #ed.path.cost - Cost is the T-shirt size, summed along and-groups and minimized along - or-groups. + The cost of a node is its stated (or estimated) T-shirt size — the size + prices the whole subtree, sub-packages are not added on top. Whether they + fit is the size conflict check's job. #ed.path.est A node without a size is estimated from its sub-packages: at least the diff --git a/frontend/public/llms.md b/frontend/public/llms.md index 92c1973..3860e9c 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -111,6 +111,10 @@ One node per line. Everything except the label is optional. label. `"(L)"` and `((L))` mention a size literally (quoting convention). - From `(M)` upward a node **should be decomposed further**; a node ≥ M without children gets a placeholder hint in the diagram. +- **A size prices its whole subtree.** For the cheapest path, the cost of a + node is its own stated size — sub-packages are **not** added on top; the + conflict check below guards whether they fit. A done node (`[x]`/`[^]`) + costs 0. - For cost estimation a missing size is **estimated from the sub-packages**: at least the largest size among the counting children (same children as the conflict check below, except unsized children count too — estimated diff --git a/frontend/src/model.js b/frontend/src/model.js index c90469a..19a698b 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -118,15 +118,14 @@ export function assumedSize(n){ } /* fehlende Größe wird geschätzt (assumedSize); Erledigtes kostet nichts mehr */ export function ownCost(n){ return isDone(n) ? 0 : SIZE_RANK[assumedSize(n)] + 1; } -export function cheapestCost(n){ - const kids = pathChildren(n); - let c = ownCost(n); - if(kids.length){ - if(gateOf(kids) !== 'and') c += Math.min(...chosenPool(kids).map(cheapestCost)); - else c += kids.reduce((s, k) => s + cheapestCost(k), 0); - } - return c; -} +/* Die Größe bepreist den GANZEN Teilbaum (SPEC §9, D69): Wer ein Paket mit + (S) bewertet hat, hat den Teilbaum bewertet — die Teilpakete kommen nicht + noch einmal obendrauf. Ob sie in die Größe passen, prüft der + Größen-Konflikt (§5/D62); fehlt die Größe, vertritt die Schätzung sie + (assumedSize, D66). Der Preis einer Alternative ist damit schlicht ihr + eigener — die frühere Rekursion (eigene Größe plus Summe/Minimum der + Kinder) bestrafte gerade die sorgfältig zerlegten Pakete. */ +export function cheapestCost(n){ return ownCost(n); } /* ---------- Günstigster Pfad auf der Dependency Closure (SPEC §9, D42) ---- Mit Abhängigkeiten zählt nicht mehr der gewählte Teilbaum, sondern die HÜLLE: Jeder nötige Knoten zieht seine `:#…`-Ziele samt deren Realisierung @@ -216,7 +215,20 @@ export function computeCheapPlan(roots){ } return set; }; - const costOf = set => { let c = 0; set.forEach(n => c += ownCost(n)); return c; }; + /* Preis einer Belegung (D69): Jeder nötige Knoten zählt nur mit dem, was + seine Größe über die nötigen Teilpakete HINAUS behauptet (nie negativ) — + die Summe bepreist einen Teilbaum so mit seiner Größe, statt + Zerlegungstiefe zu bestrafen; gemeinsam Gebrauchtes zählt über die + Mengen-Vereinigung weiterhin nur einmal. */ + const costOf = set => { + let c = 0; + set.forEach(n => { + let kids = 0; + for(const k of pathChildren(n)) if(set.has(k)) kids += ownCost(k); + c += Math.max(0, ownCost(n) - kids); + }); + return c; + }; let product = 1; for(const grp of coupled){ product *= grp.pool.length; if(product > EXACT_LIMIT) break; } diff --git a/frontend/tests/frontier.test.js b/frontend/tests/frontier.test.js index c1925c6..5d13662 100644 --- a/frontend/tests/frontier.test.js +++ b/frontend/tests/frontier.test.js @@ -65,10 +65,15 @@ describe('Kosten — Erledigtes zählt nicht mehr', () => { expect(ownCost(a)).toBe(0); }); - it('zieht nur die eigenen Kosten ab, nicht den Teilbaum', () => { + it('der erledigte Knoten bepreist 0; sein offenes Kind bleibt auf dem Pfad', () => { + /* Seit D69 bepreist die Größe den ganzen Teilbaum — der erledigte + Elternknoten kostet nichts mehr, das offene Kind bleibt trotzdem + nötig und behält seinen eigenen Preis. */ const [n] = roots(`[x] Eltern (L) - [ ] Kind (S)`); - expect(cheapestCost(n)).toBe(cheapestCost(roots('[ ] Kind (S)')[0])); + expect(cheapestCost(n)).toBe(0); + const set = computeCheapSet([n]); + expect(set.has(n.children[0])).toBe(true); }); }); diff --git a/frontend/tests/optional.test.js b/frontend/tests/optional.test.js index ef6f15d..ee7a0d5 100644 --- a/frontend/tests/optional.test.js +++ b/frontend/tests/optional.test.js @@ -48,9 +48,11 @@ describe('Günstigster Pfad — unangetastete Zugaben sind nicht nötig', () => expect(cheapLabels(BAUM)).toEqual(['Pflicht', 'Wurzel']); }); - it('rechnet die Zugabe nicht in die Kosten des Elternknotens', () => { - /* Wurzel (S=2) + Pflicht (S=2) = 4; mit der XXL-Zugabe wären es 10. */ - expect(cheapestCost(roots(BAUM)[0])).toBe(4); + it('die bewertete Wurzel bepreist ihren Teilbaum — die Zugabe erst recht nicht', () => { + /* Seit D69 ist der Preis die eigene Größe: Wurzel (S) = 2, unabhängig + von Pflicht und XXL-Zugabe. Dass Zugaben auch die GESCHÄTZTE Größe + eines größenlosen Knotens nicht erhöhen, sichern die D66-Tests. */ + expect(cheapestCost(roots(BAUM)[0])).toBe(2); }); it('vergleicht Alternativen ohne deren Zugaben', () => { @@ -105,9 +107,12 @@ describe('Günstigster Pfad — an einer angefangenen Zugabe wird gearbeitet', ( .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('die angefangene Zugabe behält ihren eigenen Preis auf dem Pfad', () => { + /* Seit D69 bepreist die Wurzel nur sich selbst (S = 2); die angefangene + Zugabe liegt mit ihrem eigenen Preis daneben auf dem Pfad. */ + const [wurzel] = roots(`[ ] Wurzel (S)\n + [~] Zugabe (S)`); + expect(cheapestCost(wurzel)).toBe(2); + expect(cheapestCost(wurzel.children[0])).toBe(2); }); it('macht sie zur Station statt des Elternknotens', () => { diff --git a/frontend/tests/pricing.test.js b/frontend/tests/pricing.test.js new file mode 100644 index 0000000..ac8d40d --- /dev/null +++ b/frontend/tests/pricing.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { cheapestCost, computeCheapSet, computeCheapPlan } from '../src/model.js'; + +/* Die Größe bepreist den ganzen Teilbaum (SPEC §9, D69): Der Preis eines + Knotens ist seine angegebene (oder nach D66 geschätzte) Größe — die + Teilpakete kommen nicht noch einmal obendrauf. Ob sie hineinpassen, prüft + der Größen-Konflikt (§5/D62); die Pfadrechnung zweifelt die Bewertung + nicht an. */ + +const roots = txt => parse(txt).roots; +const cheapLabels = txt => { + const r = roots(txt); + return [...computeCheapSet(r)].map(n => n.label).sort(); +}; + +describe('Die Größe bepreist den Teilbaum (D69)', () => { + it('der gemeldete Fall: die zerlegte S-Alternative schlägt das L', () => { + /* Vorher: Manuell = S(2) + XS(1) + S(2) = 5 > Failover L(4) — die + sorgfältige Zerlegung wurde bestraft. Jetzt zählt die Bewertung: + S(2) < L(4). */ + const txt = `- kc + - Verfügbarkeit + | Manuell mit Downtime (S) + - Maintainance Mode (XS) + - Recovery Plan (S) + + Seite Wartungsarbeiten (S) + | Failover (L) + | HA (XXL)`; + const labels = cheapLabels(txt); + expect(labels).toContain('Manuell mit Downtime'); + expect(labels).not.toContain('Failover'); + }); + + it('der Preis eines bewerteten Knotens ist seine Größe, Kinder egal', () => { + const [n] = roots(`- [ ] Paket (S) + - [ ] A (XS) + - [ ] B (S)`); + expect(cheapestCost(n)).toBe(2); + }); + + it('die Bewertung gilt auch, wenn die Kinder zu groß sind (D62 warnt dann)', () => { + /* Vier S unter einem S sind ein sizeConflict — der Fehlermarker meldet + es, aber der Knoten bleibt bewertet, wie er bewertet wurde. */ + const txt = `- [ ] Wahl (XS) + | [ ] A (S) + - [ ] a1 (S) + - [ ] a2 (S) + - [ ] a3 (S) + - [ ] a4 (S) + | [ ] B (M)`; + expect(parse(txt).warnings.some(w => w.type === 'sizeConflict')).toBe(true); + const labels = cheapLabels(txt); + expect(labels).toContain('A'); + expect(labels).not.toContain('B'); + }); + + it('ohne Größe vertritt die D66-Schätzung die Bewertung', () => { + /* Alternative ohne Größe mit einem L-Kind wird als L geschätzt und + verliert gegen das bewertete M. */ + const labels = cheapLabels(`- [ ] Wahl (XS) + | [ ] Unbewertet + - [ ] Kind (L) + | [ ] Bewertet (M)`); + expect(labels).toContain('Bewertet'); + expect(labels).not.toContain('Unbewertet'); + }); + + it('das Vereinigungs-Maß bestraft Zerlegungstiefe nicht (D42/D69)', () => { + /* Beide Alternativen ziehen je ein M-schweres Ziel: A ein zerlegtes + (M mit zwei S-Kindern), B ein grobes (L). Über die Marginal-Summe + wiegen beide 4 — Gleichstand, die erste Alternative (A) gewinnt. + Die frühere Knoten-Summe zählte das zerlegte Ziel mit 3+2+2 = 7 und + ließe B gewinnen. */ + const txt = `- [ ] Wurzel (XS) + - [ ] Wahl (XS) + | [ ] A (S) :#t1 + | [ ] B (S) :#t2 + + [ ] Extras (XS) + - [ ] #t1: Zerlegt (M) + - [ ] t1a (S) + - [ ] t1b (S) + - [ ] #t2: Grob (L)`; + const { set, exact } = computeCheapPlan(roots(txt)); + const labels = [...set].map(n => n.label); + expect(exact).toBe(true); + expect(labels).toContain('A'); + expect(labels).not.toContain('B'); + }); +});