From a918c75286a8f82fbcc22b683c025e345ba2f956 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Sat, 22 Aug 2026 19:10:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20G=C3=BCnstigster=20Pfad=20auf=20der=20D?= =?UTF-8?q?ependency=20Closure=20=E2=80=94=20exakt=20=C3=BCber=20gekoppelt?= =?UTF-8?q?e=20Gruppen=20(SPEC=20=C2=A79,=20D42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder nötige Knoten zieht seine :#…-Ziele samt Realisierung in die nötige Menge; gemeinsam Gebrauchtes zählt über die Vereinigung nur einmal. Verfahren gewählt und benannt (D34): erschöpfende Suche, aber nur über die GEKOPPELTEN Gruppen (Teilbaum enthält Abhängigkeiten oder wird gebraucht) — ungekoppelte wählen lokal wie bisher, ohne Abhängigkeiten bleibt es bei genau einer Auswertung zum alten Preis. Lexikografischer Odometer, strikt < : bei Gleichstand die erste Alternative. Oberhalb des Suchlimits (20 000) gierig mit zeilenloser Warnung cheapApprox statt stillschweigender Pseudo-Optimalität. Abhängigkeiten ziehen auch optionale und nicht gewählte Ziele (ohne deren Vorfahren), verworfene nie. Neue API computeCheapPlan {set,exact}; markCheapest entfällt. 8 neue Tests (tests/closure.test.js). Co-Authored-By: Claude Fable 5 --- docs/DECISIONS.md | 41 +++++++++++ docs/SPEC.md | 26 +++++-- docs/TASKS.md | 7 +- frontend/src/app.js | 21 +++++- frontend/src/model.js | 124 +++++++++++++++++++++++++++++---- frontend/src/warnings.js | 5 ++ frontend/tests/closure.test.js | 98 ++++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 frontend/tests/closure.test.js diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 64a162f..0cb5488 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1985,3 +1985,44 @@ Kante — ein Kreis von einem Knoten zu sich selbst sagte nichts. auf dem DOM; headless getestet sind die `data-id`/`data-deps`-Attribute des Renderers, geprüft wurde im Browser (alle drei Modi, Fokus- und Cursor-Hervorhebung, Export). + +## D42 — Closure-Pfad: erschöpfend über die gekoppelten Gruppen, gierig nur benannt +Die letzte Baustelle aus D34: Mit Abhängigkeiten zählt der günstigste Pfad +nicht mehr den gewählten Teilbaum, sondern die **Hülle** — jeder nötige Knoten +zieht seine Ziele samt Realisierung nach, gemeinsam Gebrauchtes zählt über die +Mengen-Vereinigung nur einmal. Damit ist die Wahl je Alternativgruppe nicht +mehr lokal optimal (das D34-Beispiel: `A (S) :#db` schlägt `B (M)`, sobald +`#db` ohnehin bezahlt wird). Die Entscheidungen: + +**Verfahren: erschöpfende Suche — aber nur über die gekoppelten Gruppen.** +D34 stellte „exakt, die Bäume sind klein“ gegen „gierig, aber benannt“; +gebaut sind beide, mit einer Beobachtung dazwischen, die die exakte Suche +praktisch immer billig macht: **Nur Gruppen, in deren Teilbäumen +Abhängigkeiten stehen oder auf deren Knoten welche zeigen, koppeln +überhaupt.** Alle übrigen wählen weiterhin lokal (kleinste rekursive Kosten, +Gleichstand ⇒ erste) — ohne Abhängigkeiten gibt es null gekoppelte Gruppen +und genau eine Auswertung, also exakt das alte Verhalten zum alten Preis. +Über die gekoppelten Gruppen läuft ein lexikografischer Odometer (frühe +Gruppen wechseln zuletzt, strikt `<` gewinnt — bei Gleichstand bleibt so die +erste Alternative, §9). Übersteigt das Produkt der Gruppengrößen das +Suchlimit (20 000), fällt die Rechnung auf die gierige lokale Wahl zurück +und **sagt es**: zeilenlose Warnung `cheapApprox` — die in D34 verlangte +Benennung, statt stillschweigend Optimalität zu suggerieren. + +**Abhängigkeiten ziehen, was sie brauchen — auch Optionales und nicht +Gewähltes.** `+` heißt „für das Ganze entbehrlich“, aber wer per `:#…` darauf +zeigt, braucht es eben doch; und ein Ziel in einer nicht gewählten +Alternative wird trotzdem realisiert, wenn etwas Nötiges davon abhängt. Im +Diagramm bleibt so ein einzelner heller Knoten im zurückgetretenen Zweig +stehen — sichtbar „das hier wird gebraucht, egal wie ihr wählt“. Nur +**verworfene** Ziele werden nie gezogen (§9: verworfen zählt nie): Sie werden +nicht realisiert, und dass der Abhängige deshalb nie fertig wird, sagt schon +der effektive Status (D39) — der Pfad muss die Lüge nicht einpreisen. +Gezogen wird das Ziel samt **Abwärts**-Realisierung, nicht seine Vorfahren: +Der Elternknoten braucht sein Kind, nicht umgekehrt. + +**API: `computeCheapPlan(roots)` → `{set, exact}`.** `computeCheapSet` bleibt +als Hülle für Tests und Kompatibilität; `render()` liest `exact` für die +Warnung. `markCheapest` entfällt — die Menge entsteht jetzt in einem +Worklist-Durchlauf je Belegung (Zyklen enden über die Mengen-Prüfung von +selbst; erste ID-Vergabe gewinnt, D36/D39). diff --git a/docs/SPEC.md b/docs/SPEC.md index 661715e..fcc3c0f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -423,6 +423,24 @@ werden die für die günstigste Realisierung **nötigen** Knoten: - **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die SPEC-Semantik der Größen in §5 bleibt unberührt). +**Mit Abhängigkeiten (§1) zählt die Dependency Closure.** Jeder nötige Knoten +zieht seine `:#…`-Ziele samt deren Realisierung in die nötige Menge; gemeinsam +Gebrauchtes zählt über die Mengen-Vereinigung nur **einmal**. Regeln: + +- Abhängigkeiten ziehen ihr Ziel auch dann, wenn es **optional** ist oder in + einer **nicht gewählten Alternative** steht — gebraucht ist gebraucht; im + Diagramm bleibt so ein einzelner heller Knoten in einem zurückgetretenen + Zweig stehen. Nur **verworfene** Ziele werden nie gezogen (verworfen zählt + nie); dass so ein Knoten nicht fertig werden kann, zeigt der effektive + Status (§4). +- Damit ist die Wahl je Alternativgruppe **nicht mehr lokal**: Eine teurere + Alternative kann gewinnen, weil ihre Abhängigkeiten anderswo ohnehin + 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 + und sagt es** — Warnung `cheapApprox`, zeilenlos. + Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück (blass, entsättigt); der @@ -758,11 +776,9 @@ Knoten, Hervorhebung am ausgewählten Knoten. Begründung: D41. ### Günstigster Pfad mit Abhängigkeiten -Die Kostenrechnung aus §9 zählt heute nur den gewählten Teilbaum. Mit -Abhängigkeiten zählt die **Dependency Closure**: alles, was zusätzlich nötig -ist, damit der gewählte Knoten effektiv fertig werden kann. Gemeinsam -benötigte Abhängigkeiten werden dabei **nur einmal** gezählt. Genau das macht -die Rechnung schwerer als heute — siehe D34. +**Umgesetzt** — siehe §9 (Dependency Closure): Vereinigung statt Teilbaum, +gemeinsam Gebrauchtes zählt einmal, erschöpfende Suche über die gekoppelten +Gruppen mit benanntem gierigem Rückfall. Begründung: D42. ### Knotenbeschreibungen (`"` und `---`) diff --git a/docs/TASKS.md b/docs/TASKS.md index 91a0099..0e19c07 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -90,10 +90,15 @@ entscheiden, **bevor** Code entsteht. Minimum); **Knotenfarbe = effektiver Status**, bei Diskrepanz die eigene Statusbox als Marke unten links in den eigenen Farben (auch im Export); Tooltip + `a11yEffective`; `tests/effective.test.js`. -- [ ] Günstigsten Pfad auf die Dependency Closure umstellen (gemeinsame +- [x] Günstigsten Pfad auf die Dependency Closure umstellen (gemeinsame Abhängigkeiten nur einmal zählen). Erweitert D18; die gierige Wahl je Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und **benennen**. + → Umgesetzt (D42): erschöpfende Suche über die gekoppelten Gruppen + (`computeCheapPlan` in model.js), ungekoppelte wählen lokal wie bisher; + oberhalb des Suchlimits gierig mit zeilenloser Warnung `cheapApprox`. + Abhängigkeiten ziehen auch optionale/nicht gewählte Ziele, verworfene + nie; `tests/closure.test.js`. - [x] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär); bei ausgewähltem Knoten ein-/ausgehende hervorheben. → Umgesetzt (D41): gekrümmte blassgraue Kanten mit Pfeilspitze auf das diff --git a/frontend/src/app.js b/frontend/src/app.js index 5b396a3..0e6df3e 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +1,6 @@ import './style.css'; import { parse } from './parser.js'; -import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js'; +import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; import { padUrls } from './remote.js'; @@ -90,7 +90,15 @@ function render(){ freshSet = new Set(); foldByLine = new Map(); } else { - const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); + /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte + Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung + BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */ + let cheapSet = new Set(); + if(cheapPathOn){ + const plan = computeCheapPlan(roots); + cheapSet = plan.set; + if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]); + } out.classList.toggle('cheap-on', cheapPathOn); /* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade gerendert werden — `freshProdSet` liefert Knoten aus `roots`. Eine früher @@ -1277,6 +1285,7 @@ const I18N = { unknownDepWarn:"Zeile {line}: Abhängigkeit #{id} — es gibt keinen Knoten mit dieser ID.", unknownDescWarn:"Zeile {line}: Beschreibung für #{id} — es gibt keinen Knoten mit dieser ID.", descStrayWarn:"Zeile {line}: Beschreibungszeile ohne Bezug — ihr fehlt der Knoten bzw. der #id-Block davor.", + cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", @@ -1352,6 +1361,7 @@ const I18N = { unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.", unknownDescWarn:"Line {line}: description for #{id} — no node has this ID.", descStrayWarn:"Line {line}: description line with nothing to attach to — it needs a node or an #id block before it.", + cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", @@ -1427,6 +1437,7 @@ const I18N = { unknownDepWarn:"Línea {line}: dependencia #{id} — ningún nodo tiene esta ID.", unknownDescWarn:"Línea {line}: descripción para #{id} — ningún nodo tiene esta ID.", descStrayWarn:"Línea {line}: línea de descripción sin referencia — le falta un nodo o un bloque #id delante.", + cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", @@ -1502,6 +1513,7 @@ const I18N = { unknownDepWarn:"Ligne {line} : dépendance #{id} — aucun nœud ne porte cet ID.", unknownDescWarn:"Ligne {line} : description pour #{id} — aucun nœud ne porte cet ID.", descStrayWarn:"Ligne {line} : ligne de description sans rattachement — il lui manque un nœud ou un bloc #id avant.", + cheapApproxWarn:"Trop de groupes d’alternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", @@ -1577,6 +1589,7 @@ const I18N = { unknownDepWarn:"Wiersz {line}: zależność #{id} — żaden węzeł nie ma tego ID.", unknownDescWarn:"Wiersz {line}: opis dla #{id} — żaden węzeł nie ma tego ID.", descStrayWarn:"Wiersz {line}: wiersz opisu bez odniesienia — brakuje węzła lub bloku #id przed nim.", + cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", @@ -1652,6 +1665,7 @@ const I18N = { unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.", unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.", descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.", + cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", @@ -1727,6 +1741,7 @@ const I18N = { unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।", unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।", descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।", + cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", @@ -1802,6 +1817,7 @@ const I18N = { unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。", unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。", descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。", + cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", @@ -1877,6 +1893,7 @@ const I18N = { unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。", unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。", descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。", + cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", diff --git a/frontend/src/model.js b/frontend/src/model.js index d78b04f..bb9ba9c 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -52,23 +52,119 @@ export function cheapestCost(n){ } return c; } -export function markCheapest(n, set){ - set.add(n); - const kids = pathChildren(n); - if(!kids.length) return; - if(gateOf(kids) !== 'and'){ - let best = null, bc = Infinity; - for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } - if(best) markCheapest(best, set); - } else { - for(const k of kids) markCheapest(k, set); +/* ---------- 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 + nach; gemeinsam Gebrauchtes zählt über die Mengen-Vereinigung nur EINMAL. + Damit ist die Wahl je Alternativgruppe nicht mehr lokal optimal (D34). + + Verfahren — gewählt und benannt (D42): ERSCHÖPFENDE SUCHE, aber nur über + die GEKOPPELTEN Gruppen — disjunktive Gruppen, in deren Teilbäumen + Abhängigkeiten stehen oder auf deren Knoten Abhängigkeiten zeigen. Alle + übrigen Gruppen sind von der Kopplung unberührt und wählen lokal wie + bisher (kleinste rekursive Kosten, Gleichstand ⇒ erste). Ohne + Abhängigkeiten gibt es keine gekoppelten Gruppen und genau eine + Auswertung — das alte Verhalten. Übersteigt das Produkt der + Gruppengrößen EXACT_LIMIT, fällt die Rechnung auf die gierige lokale + Wahl zurück und SAGT es (`exact:false` ⇒ Warnung `cheapApprox`). + + Regeln der Hülle: Abhängigkeiten ziehen ihr Ziel auch dann, wenn es + optional ist oder in einer nicht gewählten Alternative steht — gebraucht + ist gebraucht; nur verworfene Ziele nie (§9: verworfen zählt nie), deren + Unerfüllbarkeit zeigt der effektive Status (D39). Bei doppelter ID gilt + die erste Vergabe (D36); Zyklen enden über die Mengen-Prüfung von selbst. */ +const EXACT_LIMIT = 20000; +export function computeCheapPlan(roots){ + const nodes = [], byId = new Map(), referenced = new Set(); + (function walk(ns){ + for(const n of ns){ + nodes.push(n); + if(n.id != null && !byId.has(n.id)) byId.set(n.id, n); + walk(n.children); + } + })(roots); + for(const n of nodes) for(const d of n.deps || []) referenced.add(d); + const anyDeps = referenced.size > 0; + + /* Teilbaum berührt die Kopplung? (hat Abhängigkeiten oder wird gebraucht) */ + const touches = new Map(); + const touch = n => { + let v = (n.deps && n.deps.length > 0) || (n.id != null && referenced.has(n.id)); + for(const k of n.children) v = touch(k) || v; + touches.set(n, v); + return v; + }; + roots.forEach(touch); + + /* Wahlpunkte: disjunktive Gruppen mit mehr als einer Alternative. + Lokale Wahl vorab (kleinste rekursive Kosten, Gleichstand ⇒ erste) — + sie gilt für ungekoppelte Gruppen und für den gierigen Rückfall. */ + const coupled = [], localChoice = new Map(); + const groups = []; + /* Nur Kindergruppen — Wurzeln sind immer alle nötig (wie bisher). */ + (function groupsOf(ns){ + for(const n of ns){ + const kids = pathChildren(n); + if(kids.length > 1 && gateOf(kids) !== 'and') groups.push(kids); + groupsOf(n.children); + } + })(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; } } + localChoice.set(kids[0], best); /* Schlüssel: erstes Kind der Gruppe */ + if(anyDeps && kids.some(k => touches.get(k))) coupled.push(kids); } + + /* Nötige Menge für eine Belegung der gekoppelten Gruppen. */ + const needed = choice => { + const set = new Set(), queue = [...roots]; + while(queue.length){ + const n = queue.pop(); + if(set.has(n)) continue; + set.add(n); + const kids = pathChildren(n); + if(kids.length){ + if(gateOf(kids) !== 'and'){ + queue.push(kids.length === 1 ? kids[0] + : (choice.get(kids[0]) || localChoice.get(kids[0]))); + } else for(const k of kids) queue.push(k); + } + for(const d of n.deps || []){ + const t = byId.get(d); + if(t && !(t.status && t.status.key === 'verworfen')) queue.push(t); + } + } + return set; + }; + 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; } + if(product > EXACT_LIMIT){ + /* Gierig, aber benannt (D42): lokale Wahl überall, Hülle trotzdem. */ + return {set: needed(new Map()), exact: false}; + } + + /* Erschöpfend, lexikografisch — frühere Gruppen wechseln zuletzt, strikt + kleiner gewinnt: Bei Gleichstand bleibt so die erste Alternative (§9). */ + const idx = coupled.map(() => 0); + let best = null, bc = Infinity; + for(;;){ + const choice = new Map(); + coupled.forEach((kids, g) => choice.set(kids[0], kids[idx[g]])); + 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--; } + if(g < 0) break; + } + return {set: best, exact: true}; } -/* Menge der nötigen Knoten über alle Wurzeln. */ +/* Menge der nötigen Knoten über alle Wurzeln (Rückgabe wie bisher). */ export function computeCheapSet(roots){ - const set = new Set(); - roots.forEach(r => markCheapest(r, set)); - return set; + return computeCheapPlan(roots).set; } /* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ⇒ ''. Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */ diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js index bdc9988..6da26ea 100644 --- a/frontend/src/warnings.js +++ b/frontend/src/warnings.js @@ -25,6 +25,9 @@ Beschreibungsteil ohne #id-Block; meldet auch die von einem versehentlichen `---` verschluckten Knotenzeilen + - cheapApprox { } — günstigster Pfad nur gierig geschätzt: + zu viele gekoppelte Gruppen für die + exakte Suche (D42); zeilenlos - sourceLoad { url, error } — ?sourceUrl= nicht ladbar (D23); ohne Zeilennummer, erscheint dadurch zuoberst - padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht @@ -55,6 +58,8 @@ export function formatWarning(w, t){ return t('unknownDescWarn', {line: w.line, id: esc(w.id)}); case 'descStray': return t('descStrayWarn', {line: w.line}); + case 'cheapApprox': + return t('cheapApproxWarn'); case 'sourceLoad': return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); case 'padRateLimit': diff --git a/frontend/tests/closure.test.js b/frontend/tests/closure.test.js new file mode 100644 index 0000000..015759c --- /dev/null +++ b/frontend/tests/closure.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { computeCheapPlan, computeCheapSet } from '../src/model.js'; + +const roots = txt => parse(txt).roots; +const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort(); + +/* Günstigster Pfad auf der Dependency Closure (SPEC §9, D42). */ +describe('Closure — Abhängigkeiten ziehen ihr Ziel in die nötige Menge', () => { + it('zieht das Ziel samt seiner Realisierung nach', () => { + expect(cheapLabels(`[ ] W (XS) + - [ ] A (S) :#db +[ ] DB #db (S) + - [ ] Schema (XS)`)) + .toEqual(['A', 'DB', 'Schema', 'W']); + }); + + it('zieht auch optionale und nicht gewählte Ziele — gebraucht ist gebraucht', () => { + const labels = cheapLabels(`[ ] W (XS) + - [ ] A (S) :#opt,#alt + + [ ] Extra #opt (S) + - [ ] Wahl (XS) + | [ ] Billig (XS) + | [ ] Teuer (XL) + - [ ] Teil #alt (XS)`); + expect(labels).toContain('Extra'); + expect(labels).toContain('Teil'); /* das Ziel selbst … */ + expect(labels).not.toContain('Teuer'); /* … aber nicht sein Vorfahr */ + expect(labels).toContain('Billig'); + }); + + it('zieht verworfene Ziele nie', () => { + expect(cheapLabels(`[ ] W (XS)\n - [ ] A (S) :#weg\n - [-] Weg #weg (XL)`)) + .toEqual(['A', 'W']); + }); +}); + +describe('Closure — gemeinsam Gebrauchtes zählt nur einmal', () => { + /* Das D34-Beispiel: A (S) :#db gegen B (M). Für sich verliert A, sobald + #db mehr als den Unterschied kostet — wird #db aber ohnehin gebraucht, + ist es bezahlt, und A gewinnt. */ + /* DB liegt unter einer Zugabe — von sich aus ist sie also NICHT nötig, + sondern nur, wenn jemand per :#db darauf zeigt. */ + const PLAN = braucht => `[ ] W (XS) + - [ ] Basis (XS)${braucht ? ' :#db' : ''} + - [ ] Wahl (XS) + | [ ] A (S) :#db + | [ ] B (M) + + [ ] Fundus (XS) + - [ ] DB #db (L)`; + + it('wählt lokal, wenn niemand sonst die Abhängigkeit bezahlt', () => { + expect(cheapLabels(PLAN(false))).not.toContain('A'); + }); + + it('wählt die Alternative mit der schon bezahlten Abhängigkeit', () => { + const labels = cheapLabels(PLAN(true)); + expect(labels).toContain('A'); + expect(labels).not.toContain('B'); + }); + + it('koppelt auch zwei Gruppen über eine geteilte Abhängigkeit', () => { + /* Beide A-Alternativen teilen sich DB: A1+A2+DB = 2+2+2 = 6, B1+B2 = + 3+3 = 6 — Gleichstand, und bei Gleichstand gewinnt die lexikografisch + erste Belegung (§9): beide Gruppen wählen A. Lokal (je Gruppe für sich: + S+S(db)=4 > M=3) hätte zweimal B gewonnen. */ + const labels = cheapLabels(`[ ] W (XS) + - [ ] G1 (XS) + | [ ] A1 (S) :#db + | [ ] B1 (M) + - [ ] G2 (XS) + | [ ] A2 (S) :#db + | [ ] B2 (M) +[ ] DB #db (S)`); + expect(labels).toEqual(expect.arrayContaining(['A1', 'A2', 'DB'])); + expect(labels).not.toContain('B1'); + expect(labels).not.toContain('B2'); + }); +}); + +describe('Verfahren — exakt bis zum Limit, dann gierig und benannt', () => { + it('bleibt ohne Abhängigkeiten exakt mit genau dem alten Ergebnis', () => { + const plan = computeCheapPlan(roots(`[ ] W (XS)\n | [ ] A (L)\n | [ ] B (S)`)); + expect(plan.exact).toBe(true); + expect([...plan.set].map(n => n.label).sort()).toEqual(['B', 'W']); + }); + + it('meldet die gierige Schätzung, wenn zu viele Gruppen koppeln', () => { + /* 15 gekoppelte Zweiergruppen: 2^15 = 32768 > 20000. */ + let txt = `[ ] Ziel #z (S)\n`; + for(let g = 0; g < 15; g++){ + txt += `[ ] G${g} (XS)\n | [ ] A${g} (S) :#z\n | [ ] B${g} (S)\n`; + } + const plan = computeCheapPlan(roots(txt)); + expect(plan.exact).toBe(false); + expect(plan.set.size).toBeGreaterThan(0); + }); +});