Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions .eamos-core/tools/emit_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,22 @@ def _trunc(s, n):


def build_mindmap_svg(ir):
"""A deterministic horizontal mind map from a graph-IR: root → branches → leaves."""
"""A deterministic mind map from a graph-IR: root → branches → leaves. The `orientation`
param picks the layout (#63) — horizontal (root left, reading rows) or vertical (root top,
one column per branch). Both are pure functions of the branch order (RFC-0002 §11-1)."""
if ir.get("orientation") == "vertical":
return _mindmap_vertical(ir)
return _mindmap_horizontal(ir)


def _box(parts, x, y, w, text, fill, txt, size=14, bold=False):
parts.append(f'<rect x="{x}" y="{y - 17}" width="{w}" height="34" rx="8" fill="{fill}"/>')
parts.append(f'<text x="{x + 12}" y="{y + 5}" font-family="Calibri, Arial, sans-serif" '
f'font-size="{size}" font-weight="{"bold" if bold else "normal"}" '
f'fill="{txt}">{esc(text)}</text>')


def _mindmap_horizontal(ir):
bg, accent, body, amber, muted, card = THEMES["professional"]
branches = ir.get("branches", [])
row_h = 46
Expand All @@ -141,25 +156,19 @@ def build_mindmap_svg(ir):
f'<rect width="{W}" height="{H}" fill="{bg}"/>']
root_y = H // 2

def box(x, y, w, text, fill, txt, size=14, bold=False):
parts.append(f'<rect x="{x}" y="{y - 17}" width="{w}" height="34" rx="8" fill="{fill}"/>')
parts.append(f'<text x="{x + 12}" y="{y + 5}" font-family="Calibri, Arial, sans-serif" '
f'font-size="{size}" font-weight="{"bold" if bold else "normal"}" '
f'fill="{txt}">{esc(text)}</text>')

def link(x1, y1, x2, y2):
mx = (x1 + x2) // 2
parts.append(f'<path d="M{x1} {y1} C{mx} {y1} {mx} {y2} {x2} {y2}" stroke="{muted}" '
f'fill="none" stroke-width="1.5"/>')

box(rx, root_y, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True)
_box(parts, rx, root_y, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True)
y = 40
for b in branches:
leaves = b.get("leaves", []) or []
span = max(1, len(leaves)) * row_h
by = y + span // 2
link(rx + 320, root_y, bx, by)
box(bx, by, 300, _trunc(b.get("label", ""), 40), card, accent, 14, True)
_box(parts, bx, by, 300, _trunc(b.get("label", ""), 40), card, accent, 14, True)
ly = y + row_h // 2
for leaf in leaves:
link(bx + 300, by, lx, ly)
Expand All @@ -172,6 +181,41 @@ def link(x1, y1, x2, y2):
return "\n".join(parts) + "\n"


def _mindmap_vertical(ir):
"""The vertical layout (#63): root on top, one column per branch, leaves stacked beneath.
Deterministic like its horizontal twin — a pure function of the branch order."""
bg, accent, body, amber, muted, card = THEMES["professional"]
branches = ir.get("branches", [])
col_w, box_w, row_h, m = 320, 300, 30, 30
n = max(1, len(branches))
depth = max([len(b.get("leaves", []) or []) for b in branches] or [0])
W = m * 2 + n * col_w - (col_w - box_w)
H = 180 + depth * row_h + 40
parts = [f'<svg viewBox="0 0 {W} {H}" xmlns="http://www.w3.org/2000/svg">',
f'<rect width="{W}" height="{H}" fill="{bg}"/>']

def vlink(x1, y1, x2, y2):
my = (y1 + y2) // 2
parts.append(f'<path d="M{x1} {y1} C{x1} {my} {x2} {my} {x2} {y2}" stroke="{muted}" '
f'fill="none" stroke-width="1.5"/>')

root_cx = W // 2
_box(parts, root_cx - 160, 57, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True)
for i, b in enumerate(branches):
x = m + i * col_w
bcx = x + box_w // 2
vlink(root_cx, 74, bcx, 123)
_box(parts, x, 140, box_w, _trunc(b.get("label", ""), 40), card, accent, 14, True)
ly = 180
for leaf in b.get("leaves", []) or []:
col = amber if "⟨" in str(leaf) else body
parts.append(f'<text x="{x + 12}" y="{ly + 5}" font-family="Calibri, Arial, sans-serif" '
f'font-size="13" fill="{col}">{esc(_trunc(leaf, 40))}</text>')
ly += row_h
parts.append("</svg>")
return "\n".join(parts) + "\n"


def build_topology_svg(ir):
"""A deterministic 'layered' system-topology diagram from a topology-IR (RFC-0004): typed nodes
placed in a grid by DECLARATION ORDER (a pure function of the order — no force-directed, no
Expand Down
21 changes: 16 additions & 5 deletions .eamos-core/tools/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,19 +537,27 @@ def build_graph_ir(manifest, archetype, altitude=None, function=None):
slide = _build_section(sec, content, ledger, acc, lang)
leaves = [t for t in (_flatten_block(b) for b in slide["blocks"]) if t][:3]
branches.append({"label": slide["title"], "leaves": leaves})

# The orientation param is live (#63): stamped into the IR, the SVG emitter lays out per value.
params = _resolve_params(load_deliverable("mindmap"), _find_deliverable(manifest, "mindmap"))
orientation = params.get("orientation") or "horizontal"
review_appendix = [
{"binding": k, "value": "" if ledger[k].get("value") is None else str(ledger[k].get("value")),
"assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")}
for k in sorted(acc["assumed"])
]
return {"ir_version": IR_VERSION, "generator": GENERATOR,
"deliverable": "mindmap", "output_lang": lang, "root": manifest.get("objective", ""),
"deliverable": "mindmap", "orientation": orientation,
"output_lang": lang, "root": manifest.get("objective", ""),
"branches": branches, "review_appendix": review_appendix}, acc


def build_quiz_ir(manifest, archetype, altitude=None, function=None):
"""Project an interview quiz (quiz-IR, RFC-0002 §3, §11-3): graded questions cite a ledger
source (required); discussion questions (from decisions) are un-scored. SAME ledger."""
source (required); discussion questions (from decisions) are un-scored. SAME ledger.
The `count` param is live (#63): short caps the graded questions at 5, standard at 10, long is
uncapped — a deterministic cut over the SORTED ledger keys, so the subset is stable under
manifest reordering. Discussion questions are never capped."""
acc = _new_acc()
ident = manifest.get("identity", {}) or {}
ctx = manifest.get("context", {}) or {}
Expand All @@ -558,10 +566,13 @@ def build_quiz_ir(manifest, archetype, altitude=None, function=None):
lang = ctx.get("output_lang", "en")
stem, disc = labels.lab(lang, "core", "quiz_stem"), labels.lab(lang, "core", "quiz_disc")

params = _resolve_params(load_deliverable("interview_quiz"), _find_deliverable(manifest, "interview_quiz"))
cap = {"short": 5, "standard": 10, "long": None}.get(params.get("count") or "standard", 10)

questions = []
for key, cell in ledger.items():
if not (key.startswith("kpi.") and isinstance(cell, dict)):
continue
graded_keys = sorted(k for k, c in ledger.items() if k.startswith("kpi.") and isinstance(c, dict))
for key in graded_keys if cap is None else graded_keys[:cap]:
cell = ledger[key]
assumed = _cell_assumed(key, cell, acc) # fail closed (#53)
if assumed:
acc["assumed"][key] = cell
Expand Down
47 changes: 47 additions & 0 deletions .eamos-core/tools/tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,53 @@ def test_every_projection_is_stamped(self):
self.assertEqual(ir["generator"], "eamos-render")


class TestDeliverableParams(unittest.TestCase):
"""The registry knobs must change the output — validated-but-dead is worse than undeclared (#63)."""

def _quiz_graded(self, count):
m, arch = load("qbr-c-level")
for i in range(7): # 5 real kpi cells + 7 synthetic = 12 graded candidates
m["inputs"][f"kpi.zz{i}"] = {"label": f"Z{i}", "value": str(i), "provided": True,
"source": "x", "provenance": "sourced"}
next(d for d in m["deliverables"] if d["type"] == "interview_quiz")["count"] = count
quiz, _ = render.build_quiz_ir(m, arch)
return quiz["questions"]

def test_quiz_count_caps_graded_questions(self):
for count, expected in (("short", 5), ("standard", 10), ("long", 12)):
with self.subTest(count=count):
graded = [q for q in self._quiz_graded(count) if q["kind"] == "graded"]
self.assertEqual(len(graded), expected)

def test_quiz_count_never_caps_discussion(self):
qs = self._quiz_graded("short")
self.assertEqual(len([q for q in qs if q["kind"] == "discussion"]), 2) # both decisions kept

def test_quiz_cap_is_stable_under_manifest_reordering(self):
a = [q["q"] for q in self._quiz_graded("short") if q["kind"] == "graded"]
m, arch = load("qbr-c-level")
m["inputs"] = dict(reversed(list(m["inputs"].items()))) # same cells, new order
for i in range(7):
m["inputs"][f"kpi.zz{i}"] = {"label": f"Z{i}", "value": str(i), "provided": True,
"source": "x", "provenance": "sourced"}
next(d for d in m["deliverables"] if d["type"] == "interview_quiz")["count"] = "short"
quiz, _ = render.build_quiz_ir(m, arch)
self.assertEqual([q["q"] for q in quiz["questions"] if q["kind"] == "graded"], a)

def test_mindmap_orientation_changes_layout(self):
import emit_svg
m, arch = load("qbr-c-level")
mind, _ = render.build_graph_ir(m, arch)
self.assertEqual(mind["orientation"], "horizontal") # qbr requests horizontal
horiz = emit_svg.build_mindmap_svg(mind)
next(d for d in m["deliverables"] if d["type"] == "mindmap")["orientation"] = "vertical"
mind_v, _ = render.build_graph_ir(m, arch)
self.assertEqual(mind_v["orientation"], "vertical")
vert = emit_svg.build_mindmap_svg(mind_v)
self.assertNotEqual(horiz, vert) # the knob changes the output
self.assertEqual(vert, emit_svg.build_mindmap_svg(mind_v)) # and stays deterministic


class TestDecisionContract(unittest.TestCase):
def test_decision_contract_renders_through_md(self):
import emit_md
Expand Down
Loading