feat: Günstigster Pfad auf der Dependency Closure — exakt über gekoppelte Gruppen (SPEC §9, D42)

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 19:10:42 +02:00
co-authored by Claude Fable 5
parent afe536e33c
commit a918c75286
7 changed files with 300 additions and 22 deletions
+41
View File
@@ -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 auf dem DOM; headless getestet sind die `data-id`/`data-deps`-Attribute des
Renderers, geprüft wurde im Browser (alle drei Modi, Fokus- und Renderers, geprüft wurde im Browser (alle drei Modi, Fokus- und
Cursor-Hervorhebung, Export). 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).
+21 -5
View File
@@ -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 - **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die
SPEC-Semantik der Größen in §5 bleibt unberührt). 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 Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte
any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück
(blass, entsättigt); der (blass, entsättigt); der
@@ -758,11 +776,9 @@ Knoten, Hervorhebung am ausgewählten Knoten. Begründung: D41.
### Günstigster Pfad mit Abhängigkeiten ### Günstigster Pfad mit Abhängigkeiten
Die Kostenrechnung aus §9 zählt heute nur den gewählten Teilbaum. Mit **Umgesetzt** — siehe §9 (Dependency Closure): Vereinigung statt Teilbaum,
Abhängigkeiten zählt die **Dependency Closure**: alles, was zusätzlich nötig gemeinsam Gebrauchtes zählt einmal, erschöpfende Suche über die gekoppelten
ist, damit der gewählte Knoten effektiv fertig werden kann. Gemeinsam Gruppen mit benanntem gierigem Rückfall. Begründung: D42.
benötigte Abhängigkeiten werden dabei **nur einmal** gezählt. Genau das macht
die Rechnung schwerer als heute — siehe D34.
### Knotenbeschreibungen (`"` und `---`) ### Knotenbeschreibungen (`"` und `---`)
+6 -1
View File
@@ -90,10 +90,15 @@ entscheiden, **bevor** Code entsteht.
Minimum); **Knotenfarbe = effektiver Status**, bei Diskrepanz die eigene Minimum); **Knotenfarbe = effektiver Status**, bei Diskrepanz die eigene
Statusbox als Marke unten links in den eigenen Farben (auch im Export); Statusbox als Marke unten links in den eigenen Farben (auch im Export);
Tooltip + `a11yEffective`; `tests/effective.test.js`. 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 Abhängigkeiten nur einmal zählen). Erweitert D18; die gierige Wahl je
Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und
**benennen**. **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); - [x] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär);
bei ausgewähltem Knoten ein-/ausgehende hervorheben. bei ausgewähltem Knoten ein-/ausgehende hervorheben.
→ Umgesetzt (D41): gekrümmte blassgraue Kanten mit Pfeilspitze auf das → Umgesetzt (D41): gekrümmte blassgraue Kanten mit Pfeilspitze auf das
+19 -2
View File
@@ -1,6 +1,6 @@
import './style.css'; import './style.css';
import { parse } from './parser.js'; 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 { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js'; import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
@@ -90,7 +90,15 @@ function render(){
freshSet = new Set(); freshSet = new Set();
foldByLine = new Map(); foldByLine = new Map();
} else { } 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); out.classList.toggle('cheap-on', cheapPathOn);
/* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade /* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade
gerendert werden `freshProdSet` liefert Knoten aus `roots`. Eine früher 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.", 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.", 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.", 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_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", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", 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.", unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.",
unknownDescWarn:"Line {line}: description for #{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.", 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_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", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", 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.", 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.", 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.", 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_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", 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.", 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.", 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.", 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.", descStrayWarn:"Ligne {line} : ligne de description sans rattachement — il lui manque un nœud ou un bloc #id avant.",
cheapApproxWarn:"Trop de groupes dalternatives 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_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é", 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.", 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.", 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.", 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.", 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_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", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
@@ -1652,6 +1665,7 @@ const I18N = {
unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.", unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.",
unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.", unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.",
descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.", descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -1727,6 +1741,7 @@ const I18N = {
unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।", unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।",
unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।", unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।",
descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।", descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -1802,6 +1817,7 @@ const I18N = {
unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。", unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。",
unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。", unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。",
descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。", descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -1877,6 +1893,7 @@ const I18N = {
unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。", unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。",
unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。", unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。",
descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。", descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
+108 -12
View File
@@ -52,23 +52,119 @@ export function cheapestCost(n){
} }
return c; return c;
} }
export function markCheapest(n, 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); set.add(n);
const kids = pathChildren(n); const kids = pathChildren(n);
if(!kids.length) return; if(kids.length){
if(gateOf(kids) !== 'and'){ if(gateOf(kids) !== 'and'){
let best = null, bc = Infinity; queue.push(kids.length === 1 ? kids[0]
for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } : (choice.get(kids[0]) || localChoice.get(kids[0])));
if(best) markCheapest(best, set); } else for(const k of kids) queue.push(k);
} else { }
for(const k of kids) markCheapest(k, set); for(const d of n.deps || []){
const t = byId.get(d);
if(t && !(t.status && t.status.key === 'verworfen')) queue.push(t);
}
} }
}
/* Menge der nötigen Knoten über alle Wurzeln. */
export function computeCheapSet(roots){
const set = new Set();
roots.forEach(r => markCheapest(r, set));
return set; 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 (Rückgabe wie bisher). */
export function computeCheapSet(roots){
return computeCheapPlan(roots).set;
} }
/* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ''. /* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ''.
Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */ Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */
+5
View File
@@ -25,6 +25,9 @@
Beschreibungsteil ohne #id-Block; meldet Beschreibungsteil ohne #id-Block; meldet
auch die von einem versehentlichen auch die von einem versehentlichen
`---` verschluckten Knotenzeilen `---` 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 - sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne
Zeilennummer, erscheint dadurch zuoberst Zeilennummer, erscheint dadurch zuoberst
- padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht - 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)}); return t('unknownDescWarn', {line: w.line, id: esc(w.id)});
case 'descStray': case 'descStray':
return t('descStrayWarn', {line: w.line}); return t('descStrayWarn', {line: w.line});
case 'cheapApprox':
return t('cheapApproxWarn');
case 'sourceLoad': case 'sourceLoad':
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit': case 'padRateLimit':
+98
View File
@@ -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);
});
});