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
+19 -2
View File
@@ -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 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_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}」— 中立として表示。",
+110 -14
View File
@@ -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'. */
+5
View File
@@ -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':
+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);
});
});