Prototype geometry-driven rich graph renderer
This commit is contained in:
commit
b1a7bae1da
13 changed files with 3742 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
bin/
|
||||
obj/
|
||||
out/
|
||||
.tools/
|
||||
nupkg/
|
||||
.vs/
|
||||
.vscode/
|
||||
27
EpiphanyGraph.csproj
Normal file
27
EpiphanyGraph.csproj
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>epiphanygraph</ToolCommandName>
|
||||
<PackageId>EpiphanyGraph</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Description>Generate Obsidian note layouts with MSAGL and expose the generator as an MCP tool.</Description>
|
||||
<PackageTags>obsidian;graph;msagl;mcp</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Msagl.Drawing" Version="1.1.6" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
342
FRIENDLINESS_TUNER.md
Normal file
342
FRIENDLINESS_TUNER.md
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
# Model Friendliness Rendering Tuner
|
||||
|
||||
This tuner guides experiments that make EpiphanyGraph output easier for both humans and vision-capable agents to understand.
|
||||
|
||||
The point is not to make a prettier hairball. The point is to render graphs in ways that expose useful structure quickly, with enough machine-readable backing data to verify what the eyes think they saw.
|
||||
|
||||
## Working Hypothesis
|
||||
|
||||
A rendered graph can give an agent real visual signal about clusters, bridges, density, flow, and suspicious structure. That signal gets stronger when the rendering is stable, readable, semantically colored, and paired with exact layout JSON.
|
||||
|
||||
Do not rely on visual intuition alone. Use the image for visceral structure and the JSON for truth.
|
||||
|
||||
## Current Scope
|
||||
|
||||
Focus now:
|
||||
|
||||
- sweep 2D layout algorithms and parameters
|
||||
- tune readability through color, labels, spacing, stroke weight, background, and edge routing
|
||||
- preserve exact node and edge data in JSON
|
||||
- compare outputs using human and agent visual judgement
|
||||
- support separate but linked graph views, especially source tree versus control flow
|
||||
|
||||
Defer for later:
|
||||
|
||||
- fake 3D
|
||||
- layered perspective rendering
|
||||
- cross-layer pull constraints
|
||||
- multi-graph alignment driven by CFG-to-source anchors
|
||||
|
||||
3D can wait. MSAGL is not a 3D renderer, and shoving depth cues into a 2D layout before the mapping data exists is how we summon decorative mud.
|
||||
|
||||
## Design Rule
|
||||
|
||||
Use render sweeps to find views that make structure obvious before labels are read, then use structured data to verify the exact interpretation.
|
||||
|
||||
## Separate Graphs, Linked Meaning
|
||||
|
||||
Keep different graph families visually separate when they represent different kinds of truth.
|
||||
|
||||
Use separate canonical views for:
|
||||
|
||||
- source tree and ownership structure
|
||||
- call graph
|
||||
- control flow graph
|
||||
- dependency graph
|
||||
- note or concept graph
|
||||
|
||||
Connect them through explicit anchors, not one mega-graph.
|
||||
|
||||
Useful anchor chain:
|
||||
|
||||
```text
|
||||
source tree -> file -> symbol/function -> CFG block -> line span
|
||||
```
|
||||
|
||||
This gives the assistant and the human a sane bridge between architecture-scale structure and execution-scale detail.
|
||||
|
||||
## Cross-Graph Link Requirements
|
||||
|
||||
Before attempting interconnected layered graphs, collect explicit mapping data.
|
||||
|
||||
Required link data:
|
||||
|
||||
- file path for every symbol and CFG node
|
||||
- symbol name or stable symbol ID
|
||||
- source line span for CFG blocks
|
||||
- containing module or directory
|
||||
- edge type for every cross-view relationship
|
||||
|
||||
Useful edge types:
|
||||
|
||||
- `contains`
|
||||
- `defined_in`
|
||||
- `calls`
|
||||
- `branches_to`
|
||||
- `throws_to`
|
||||
- `returns_to`
|
||||
- `imports`
|
||||
- `references`
|
||||
|
||||
Do not ask visual layout to infer these relationships from proximity alone. Proximity is a hint. Typed edges are the receipt.
|
||||
|
||||
## Sweep Axes
|
||||
|
||||
Run controlled sweeps across layout and rendering parameters. Change one or two variables per batch unless the intent is broad exploration.
|
||||
|
||||
Layout algorithms:
|
||||
|
||||
- `sugiyama` for hierarchy, flow, and source-tree-like structures
|
||||
- `mds` for organic shape and cluster discovery
|
||||
- `ranking` or layered variants if MSAGL exposes useful alternatives
|
||||
- force-directed or Kamada-Kawai-style layout if added later
|
||||
|
||||
Notes:
|
||||
|
||||
- MSAGL does not currently expose a named Kamada-Kawai mode in the public settings surface we are using.
|
||||
- `mds` is the closest MSAGL-native approximation to the Obsidian or Kamada-Kawai feel.
|
||||
- Treat `mds` as "roughly the same family of organic distance-fitting layouts," not as an exact algorithm match.
|
||||
|
||||
Layout parameters:
|
||||
|
||||
- node separation
|
||||
- layer separation
|
||||
- aspect ratio
|
||||
- edge routing mode
|
||||
- spline versus straight edges
|
||||
- cluster padding
|
||||
- graph margins
|
||||
- deterministic ordering
|
||||
- fixed or pinned anchor nodes when available
|
||||
|
||||
Rendering parameters:
|
||||
|
||||
- background color
|
||||
- node fill palette
|
||||
- label font size
|
||||
- label wrapping width
|
||||
- node padding
|
||||
- stroke width
|
||||
- edge opacity
|
||||
- edge arrow style
|
||||
- edge curvature
|
||||
- shadow or halo for selected nodes
|
||||
- cluster boundary style
|
||||
- warning/error emphasis
|
||||
|
||||
Export parameters:
|
||||
|
||||
- SVG viewport size
|
||||
- high-resolution PNG rasterization if needed
|
||||
- layout JSON precision
|
||||
- stable IDs in every visual element
|
||||
- optional image filename that encodes the sweep parameters
|
||||
|
||||
## Color Semantics
|
||||
|
||||
Use color to encode meaning, not decoration.
|
||||
|
||||
Suggested source view categories:
|
||||
|
||||
- entry or selected node
|
||||
- directory or module
|
||||
- source file
|
||||
- test file
|
||||
- generated file
|
||||
- external dependency
|
||||
- orphan or unreachable node
|
||||
- warning or ambiguous link
|
||||
|
||||
Suggested CFG categories:
|
||||
|
||||
- function entry
|
||||
- normal block
|
||||
- branch or decision block
|
||||
- loop block
|
||||
- return block
|
||||
- throw or error block
|
||||
- call site
|
||||
- merge point
|
||||
|
||||
Color rules:
|
||||
|
||||
- keep category colors consistent across sweeps
|
||||
- use contrast high enough for labels and edge crossings
|
||||
- avoid palettes where adjacent categories differ only by vibe
|
||||
- reserve red or hot warning colors for real problems
|
||||
- prefer subtle backgrounds over pure white only if readability improves
|
||||
|
||||
## Readability Rubric
|
||||
|
||||
Score each candidate output against the real job.
|
||||
|
||||
Use a 1 to 5 score for:
|
||||
|
||||
- label legibility
|
||||
- edge traceability
|
||||
- cluster readability
|
||||
- flow readability
|
||||
- hub visibility
|
||||
- bridge visibility
|
||||
- isolate visibility
|
||||
- visual clutter control
|
||||
- stability across similar inputs
|
||||
- correspondence between image and JSON
|
||||
|
||||
Reject candidates when:
|
||||
|
||||
- labels require zooming before any structure is apparent
|
||||
- edge crossings dominate the image
|
||||
- color does not encode meaningful categories
|
||||
- the layout looks balanced but hides flow
|
||||
- the graph changes shape wildly after small input changes
|
||||
- important nodes are visually buried
|
||||
- the image cannot be explained using the JSON
|
||||
|
||||
## Benchmark Harness
|
||||
|
||||
Use the local harness in `benchmarks/` when this needs to become measurement instead of taste.
|
||||
|
||||
Current command:
|
||||
|
||||
```powershell
|
||||
.\scripts\New-BenchmarkRun.ps1
|
||||
```
|
||||
|
||||
The harness renders each candidate, writes blind probe sheets, keeps gold answers separate, and lets a Codex-only review pass score whether the image actually helped recover graph truth.
|
||||
|
||||
Primary metric:
|
||||
|
||||
- correct answers to grounded graph probes
|
||||
|
||||
Secondary diagnostics:
|
||||
|
||||
- label legibility
|
||||
- edge traceability
|
||||
- cluster readability
|
||||
- flow readability
|
||||
- clutter control
|
||||
|
||||
If a render wins on subjective prettiness but loses on grounded probe answers, throw the pretty one into the sea. Lovingly.
|
||||
|
||||
## Agent Review Prompt
|
||||
|
||||
Use this prompt when asking an agent to review a render sweep:
|
||||
|
||||
> Inspect the rendered graph image and the corresponding layout JSON together. First describe the major visible structures: clusters, hubs, bridges, isolates, dense regions, flow direction, and any suspicious crossings or bottlenecks. Then verify the important claims against the JSON node and edge data. Score readability from 1 to 5 for labels, edge traceability, cluster readability, flow readability, and clutter control. Prefer outputs that make meaningful structure visible quickly while preserving exact machine-readable mappings. Do not reward prettiness unless it improves understanding.
|
||||
|
||||
## Sweep Output Format
|
||||
|
||||
Each sweep candidate should produce:
|
||||
|
||||
- rendered SVG
|
||||
- optional PNG preview
|
||||
- layout JSON
|
||||
- warnings file
|
||||
- parameter manifest
|
||||
- short review note
|
||||
|
||||
Recommended candidate directory shape:
|
||||
|
||||
```text
|
||||
out/sweeps/<sweep-name>/
|
||||
manifest.json
|
||||
candidates/
|
||||
001-sugiyama-light-large-labels/
|
||||
graph.svg
|
||||
graph.json
|
||||
warnings.txt
|
||||
review.md
|
||||
002-mds-warm-bg-curved-edges/
|
||||
graph.svg
|
||||
graph.json
|
||||
warnings.txt
|
||||
review.md
|
||||
```
|
||||
|
||||
## When MSAGL Is Enough
|
||||
|
||||
Stay with MSAGL rendering when:
|
||||
|
||||
- the graph is single-view
|
||||
- labels are readable
|
||||
- colors and shapes are enough to encode categories
|
||||
- edge routing is acceptable
|
||||
- the SVG output can carry the needed IDs and metadata
|
||||
|
||||
## When To Use A Custom Renderer
|
||||
|
||||
Use MSAGL for layout but a custom renderer for drawing when:
|
||||
|
||||
- labels need better wrapping or placement
|
||||
- category styling outgrows MSAGL defaults
|
||||
- cross-view highlights need stable DOM IDs
|
||||
- multi-graph overlays need explicit tethers
|
||||
- the same layout must render in several visual themes
|
||||
- background, halos, shadows, or cluster containers need careful control
|
||||
- accessibility or contrast requirements matter
|
||||
|
||||
In that mode, MSAGL produces coordinates and routing. The custom renderer turns `graph.json` into the final SVG or canvas output. MSAGL gets to do math. The renderer gets to do taste. Everyone stays in their lane, blessedly.
|
||||
|
||||
## Renderer Direction
|
||||
|
||||
Assume the built-in MSAGL SVG is a baseline diagnostic artifact, not the final visual language.
|
||||
|
||||
What the raw SVG is good at:
|
||||
|
||||
- proving the layout exists
|
||||
- exposing coordinates and routing
|
||||
- making technical regressions visible
|
||||
- giving the benchmark something immediate to chew on
|
||||
|
||||
What the raw SVG is bad at:
|
||||
|
||||
- dense structural insight
|
||||
- semantic emphasis
|
||||
- expressive labels
|
||||
- rich cluster treatment
|
||||
- visual hierarchy across node classes
|
||||
- overlays, focus states, and linked-view affordances
|
||||
|
||||
Current renderer target:
|
||||
|
||||
- MSAGL computes coordinates and edge routing
|
||||
- `graph.json` remains the source of truth for node and edge semantics
|
||||
- a custom renderer draws the actual human-and-agent-facing artifact
|
||||
|
||||
Custom renderer priorities:
|
||||
|
||||
- semantically meaningful node styling by category and role
|
||||
- stronger label treatment, wrapping, truncation, and halo rules
|
||||
- visually distinct edge classes and emphasis states
|
||||
- cluster containers or region shading when it improves structure
|
||||
- linked-view highlighting between source-tree and control-flow graphs
|
||||
- stable DOM IDs and metadata hooks for interaction and benchmarking
|
||||
- multiple themes optimized for overview, dense inspection, and presentation
|
||||
|
||||
If we want rich, dense structural insight, we should stop pretending the stock SVG is the product. It is the scaffolding.
|
||||
|
||||
## Future Layered Views
|
||||
|
||||
For multiple interconnected graph layers, use explicit constraints and anchors.
|
||||
|
||||
Possible future layout model:
|
||||
|
||||
- source tree layer stays stable
|
||||
- control flow layer renders separately
|
||||
- CFG nodes align near their owning symbols or files
|
||||
- cross-layer tethers connect CFG regions to source anchors
|
||||
- selected paths highlight touched files and symbols
|
||||
- unrelated layers dim instead of disappearing
|
||||
|
||||
Do not attempt this until the code analysis pipeline can emit reliable CFG-to-file and CFG-to-symbol mappings.
|
||||
|
||||
## Short Version
|
||||
|
||||
- sweep layout algorithms and visual parameters deliberately
|
||||
- score outputs by readability and reasoning usefulness
|
||||
- color should encode meaningful categories
|
||||
- keep source graphs and CFGs separate but explicitly linked
|
||||
- use visual layout for intuition and JSON for truth
|
||||
- defer 3D until cross-layer mappings and constraints exist
|
||||
- switch to a custom renderer when MSAGL layout is useful but MSAGL drawing is too blunt
|
||||
1545
Program.cs
Normal file
1545
Program.cs
Normal file
File diff suppressed because it is too large
Load diff
178
README.md
Normal file
178
README.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# EpiphanyGraph
|
||||
|
||||
Small .NET tool for turning an Obsidian note subtree into an MSAGL layout. It still works as a CLI, and now it can also sit on stdio as an MCP server.
|
||||
|
||||
## What It Does
|
||||
|
||||
- scans markdown notes under a vault folder
|
||||
- resolves Obsidian wikilinks and regular markdown note links
|
||||
- optionally starts from an entry note and keeps only reachable notes
|
||||
- classifies notes into `source-tree` and `control-flow` graph families
|
||||
- lays out each family as its own graph partition
|
||||
- reports cross-links that still jump between those families
|
||||
- runs Microsoft Automatic Graph Layout
|
||||
- writes:
|
||||
- `manifest.json`
|
||||
- `source-tree.svg`
|
||||
- `source-tree.msagl.svg`
|
||||
- `source-tree.rich.svg`
|
||||
- `source-tree.json`
|
||||
- `control-flow.svg`
|
||||
- `control-flow.msagl.svg`
|
||||
- `control-flow.rich.svg`
|
||||
- `control-flow.json`
|
||||
- `cross-links.json`
|
||||
- `warnings.txt`
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Run from source:
|
||||
|
||||
```powershell
|
||||
dotnet run --project . -- `
|
||||
--vault-root "E:\Projects\Aetheria-Economy\Aetheria\Source Tree Map" `
|
||||
--entry-note "Source Tree Map" `
|
||||
--renderer rich `
|
||||
--output-dir ".\out\source-tree-map"
|
||||
```
|
||||
|
||||
After packing as a .NET tool:
|
||||
|
||||
```powershell
|
||||
epiphanygraph `
|
||||
--vault-root "E:\Projects\Aetheria-Economy\Aetheria\Source Tree Map" `
|
||||
--entry-note "Source Tree Map" `
|
||||
--renderer rich `
|
||||
--output-dir ".\out\source-tree-map"
|
||||
```
|
||||
|
||||
## MCP Usage
|
||||
|
||||
The MCP server exposes one tool:
|
||||
|
||||
- `generate_obsidian_graph_layout`
|
||||
|
||||
### Run From Source
|
||||
|
||||
Use this in an MCP client config when you want to launch the server straight from the repo:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"epiphanygraph": {
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project",
|
||||
"E:\\Projects\\EpiphanyGraph\\EpiphanyGraph.csproj",
|
||||
"--",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pack As A .NET Tool
|
||||
|
||||
Build the package:
|
||||
|
||||
```powershell
|
||||
dotnet pack . -c Release -o .\nupkg
|
||||
```
|
||||
|
||||
Install it from the local package output:
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global --add-source .\nupkg EpiphanyGraph
|
||||
```
|
||||
|
||||
Then point your MCP client at the installed command:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"epiphanygraph": {
|
||||
"command": "epiphanygraph",
|
||||
"args": ["--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Arguments
|
||||
|
||||
- `vaultRoot`: path to the Obsidian vault folder
|
||||
- `entryNote`: optional entry note name or relative note path
|
||||
- `outputDir`: optional output directory, defaults to `./out`
|
||||
- `layout`: `sugiyama` or `mds`
|
||||
- `renderer`: `msagl` or `rich`
|
||||
- `includeUnreachable`: keep notes that are not reachable from the entry note
|
||||
|
||||
### MCP Tool Result
|
||||
|
||||
The tool writes `manifest.json`, primary partition SVGs, raw `.msagl.svg` variants, custom `.rich.svg` variants, partition JSON files, `cross-links.json`, and `warnings.txt`, then returns:
|
||||
|
||||
- output paths for each partition
|
||||
- seed note IDs used for traversal
|
||||
- total note and edge counts
|
||||
- per-partition node and edge counts
|
||||
- warnings
|
||||
- the resolved entry note ID, if one was used
|
||||
|
||||
## Graph Families
|
||||
|
||||
By default, notes whose basename contains `Control Flow` land in the `control-flow` partition, and everything else lands in `source-tree`.
|
||||
|
||||
You can override that with frontmatter on a note:
|
||||
|
||||
```yaml
|
||||
---
|
||||
epiphany-graph-family: control-flow
|
||||
---
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```yaml
|
||||
---
|
||||
epiphany-graph-families:
|
||||
- source-tree
|
||||
- control-flow
|
||||
---
|
||||
```
|
||||
|
||||
Supported keys:
|
||||
|
||||
- `epiphany-graph-family`
|
||||
- `epiphany-graph-families`
|
||||
- `graph-family`
|
||||
- `graph-families`
|
||||
|
||||
If your chosen `entryNote` is itself one of the explicit family roots, the generator also seeds traversal from any sibling family roots it can find.
|
||||
|
||||
- `Source Tree Map` will also pull in `Control Flow Map`
|
||||
- `Control Flow Map` will also pull in `Source Tree Map`
|
||||
|
||||
That keeps structurally separated note graphs from vanishing just because they no longer link to each other directly.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
- `sugiyama`: layered, better for hierarchy-heavy note trees
|
||||
- `mds`: force-ish spread, better when the graph is cross-linked chaos
|
||||
|
||||
`mds` is the closest MSAGL-native approximation to the Obsidian or Kamada-Kawai vibe. It is not a named Kamada-Kawai mode.
|
||||
|
||||
## Renderer Modes
|
||||
|
||||
- `msagl`: the stock MSAGL SVG, useful as the plain baseline
|
||||
- `rich`: a custom SVG renderer that consumes MSAGL graph geometry and semantic metadata, then draws something with more visual hierarchy and less beige despair
|
||||
|
||||
## Notes
|
||||
|
||||
- unresolved and ambiguous links are written to `warnings.txt`
|
||||
- node IDs in the partition `.json` files are relative note paths without `.md`
|
||||
- the current implementation estimates label sizes instead of using a platform text measurer, because this is a generator, not a cathedral
|
||||
- `source-tree.svg` and `control-flow.svg` are the primary outputs for the selected renderer
|
||||
- raw MSAGL output is always preserved as `*.msagl.svg`
|
||||
- the custom renderer currently emits `*.rich.svg` from MSAGL geometry, not from post-processing the stock SVG
|
||||
689
RichSvgRenderer.cs
Normal file
689
RichSvgRenderer.cs
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Msagl.Core.Geometry.Curves;
|
||||
using Microsoft.Msagl.Drawing;
|
||||
|
||||
static class RichSvgRenderer
|
||||
{
|
||||
public static string Render(LayoutReport report, Graph graph)
|
||||
{
|
||||
var theme = Theme.For(report.GraphKey);
|
||||
var degreeByNodeId = CalculateDegrees(report);
|
||||
var layout = LayoutFrame.Create(report);
|
||||
var nodes = report.Nodes
|
||||
.Select(node => NodeVisual.Create(node, layout, theme, degreeByNodeId.GetValueOrDefault(node.Id)))
|
||||
.ToArray();
|
||||
var nodeLookup = nodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var document = new StringBuilder();
|
||||
document.AppendLine("""<?xml version="1.0" encoding="utf-8"?>""");
|
||||
document.AppendLine(
|
||||
$"""<svg xmlns="http://www.w3.org/2000/svg" width="{Fmt(layout.CanvasWidth)}" height="{Fmt(layout.CanvasHeight)}" viewBox="0 0 {Fmt(layout.CanvasWidth)} {Fmt(layout.CanvasHeight)}" role="img" aria-labelledby="title desc">""");
|
||||
AppendDefs(document, theme);
|
||||
document.AppendLine($""" <title id="title">{Escape(report.GraphTitle)} ({Escape(report.GraphKey)})</title>""");
|
||||
document.AppendLine(
|
||||
$""" <desc id="desc">EpiphanyGraph custom renderer output for {Escape(report.GraphKey)} using {Escape(report.LayoutMode)} layout with {report.NodeCount} nodes and {report.EdgeCount} edges.</desc>""");
|
||||
document.AppendLine($""" <rect x="0" y="0" width="{Fmt(layout.CanvasWidth)}" height="{Fmt(layout.CanvasHeight)}" fill="url(#bg-gradient)" />""");
|
||||
document.AppendLine($""" <rect x="0" y="0" width="{Fmt(layout.CanvasWidth)}" height="{Fmt(layout.CanvasHeight)}" fill="url(#bg-noise)" opacity="0.18" />""");
|
||||
document.AppendLine($""" <rect x="{Fmt(layout.GraphAreaX - 18)}" y="{Fmt(layout.GraphAreaY - 18)}" width="{Fmt(layout.GraphAreaWidth + 36)}" height="{Fmt(layout.GraphAreaHeight + 36)}" rx="28" fill="{theme.GraphPanelFill}" fill-opacity="0.72" stroke="{theme.GraphPanelStroke}" stroke-opacity="0.5" />""");
|
||||
AppendDepthGuides(document, report, layout, theme);
|
||||
AppendHeader(document, report, layout, theme);
|
||||
AppendEdges(document, graph, nodeLookup, layout, theme);
|
||||
AppendNodes(document, nodes, theme);
|
||||
AppendLegend(document, report, layout, theme);
|
||||
document.AppendLine("</svg>");
|
||||
return document.ToString();
|
||||
}
|
||||
|
||||
private static void AppendDefs(StringBuilder document, Theme theme)
|
||||
{
|
||||
document.AppendLine(" <defs>");
|
||||
document.AppendLine($""" <linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">""");
|
||||
document.AppendLine($""" <stop offset="0%" stop-color="{theme.BackgroundStart}" />""");
|
||||
document.AppendLine($""" <stop offset="58%" stop-color="{theme.BackgroundMid}" />""");
|
||||
document.AppendLine($""" <stop offset="100%" stop-color="{theme.BackgroundEnd}" />""");
|
||||
document.AppendLine(" </linearGradient>");
|
||||
document.AppendLine(""" <radialGradient id="bg-noise" cx="50%" cy="40%" r="80%">""");
|
||||
document.AppendLine(""" <stop offset="0%" stop-color="#ffffff" stop-opacity="0.28" />""");
|
||||
document.AppendLine(""" <stop offset="100%" stop-color="#ffffff" stop-opacity="0" />""");
|
||||
document.AppendLine(" </radialGradient>");
|
||||
document.AppendLine($""" <filter id="node-glow" x="-40%" y="-40%" width="180%" height="180%">""");
|
||||
document.AppendLine($""" <feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="{theme.GlowColor}" flood-opacity="0.18" />""");
|
||||
document.AppendLine(" </filter>");
|
||||
document.AppendLine(""" <filter id="entry-glow" x="-80%" y="-80%" width="260%" height="260%">""");
|
||||
document.AppendLine(""" <feDropShadow dx="0" dy="0" stdDeviation="8" flood-color="#FDE68A" flood-opacity="0.52" />""");
|
||||
document.AppendLine(""" <feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#F59E0B" flood-opacity="0.22" />""");
|
||||
document.AppendLine(" </filter>");
|
||||
document.AppendLine($""" <marker id="arrow-accent" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="8" markerHeight="8" orient="auto-start-reverse">""");
|
||||
document.AppendLine($""" <path d="M 0 0 L 12 6 L 0 12 z" fill="{theme.EdgeAccent}" />""");
|
||||
document.AppendLine(" </marker>");
|
||||
document.AppendLine(""" <marker id="arrow-muted" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="8" markerHeight="8" orient="auto-start-reverse">""");
|
||||
document.AppendLine(""" <path d="M 0 0 L 12 6 L 0 12 z" fill="#94A3B8" />""");
|
||||
document.AppendLine(" </marker>");
|
||||
document.AppendLine(" </defs>");
|
||||
}
|
||||
|
||||
private static void AppendDepthGuides(StringBuilder document, LayoutReport report, LayoutFrame layout, Theme theme)
|
||||
{
|
||||
var depths = report.Nodes
|
||||
.Select(node => node.Depth)
|
||||
.Where(depth => depth >= 0)
|
||||
.Distinct()
|
||||
.OrderBy(depth => depth)
|
||||
.ToArray();
|
||||
|
||||
if (depths.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var depthMax = depths.Max();
|
||||
document.AppendLine(""" <g id="depth-guides" opacity="0.7">""");
|
||||
|
||||
foreach (var depth in depths)
|
||||
{
|
||||
var y = layout.GraphAreaY + (layout.GraphAreaHeight * ((depth + 0.5) / (depthMax + 1.0)));
|
||||
var lineColor = theme.GetDepthAccent(depth, GraphPartitionNodeRole.Primary.ToApiValue());
|
||||
document.AppendLine(
|
||||
$""" <line x1="{Fmt(layout.GraphAreaX)}" y1="{Fmt(y)}" x2="{Fmt(layout.GraphAreaX + layout.GraphAreaWidth)}" y2="{Fmt(y)}" stroke="{lineColor}" stroke-opacity="0.12" stroke-width="1.2" stroke-dasharray="6 10" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.GraphAreaX + 10)}" y="{Fmt(y - 8)}" fill="{lineColor}" fill-opacity="0.58" font-family="{theme.FontFamily}" font-size="11" letter-spacing="0.18em">DEPTH {depth}</text>""");
|
||||
}
|
||||
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
private static void AppendHeader(StringBuilder document, LayoutReport report, LayoutFrame layout, Theme theme)
|
||||
{
|
||||
var familyLabel = report.GraphKey.Equals("control-flow", StringComparison.OrdinalIgnoreCase)
|
||||
? "CONTROL FLOW"
|
||||
: "SOURCE TREE";
|
||||
var subtitle = $"{report.LayoutMode.ToUpperInvariant()} layout | rich renderer | {report.NodeCount} nodes | {report.EdgeCount} edges";
|
||||
|
||||
document.AppendLine(""" <g id="header">""");
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(layout.GraphAreaX)}" y="24" width="{Fmt(layout.GraphAreaWidth)}" height="66" rx="24" fill="{theme.HeaderFill}" fill-opacity="0.82" stroke="{theme.HeaderStroke}" stroke-opacity="0.55" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.GraphAreaX + 24)}" y="53" fill="{theme.HeaderText}" font-family="{theme.FontFamily}" font-size="30" font-weight="700">{Escape(report.GraphTitle)}</text>""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.GraphAreaX + 24)}" y="76" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="12" letter-spacing="0.18em">{Escape(subtitle)}</text>""");
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(layout.SidebarX + 22)}" y="34" width="154" height="26" rx="13" fill="{theme.PillFill}" stroke="{theme.PillStroke}" stroke-opacity="0.5" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.SidebarX + 99)}" y="52" text-anchor="middle" fill="{theme.HeaderText}" font-family="{theme.FontFamily}" font-size="12" font-weight="700" letter-spacing="0.18em">{familyLabel}</text>""");
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
private static void AppendEdges(
|
||||
StringBuilder document,
|
||||
Graph graph,
|
||||
IReadOnlyDictionary<string, NodeVisual> nodeLookup,
|
||||
LayoutFrame layout,
|
||||
Theme theme)
|
||||
{
|
||||
document.AppendLine(""" <g id="edges" fill="none">""");
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!nodeLookup.TryGetValue(edge.SourceNode.Id, out var source) || !nodeLookup.TryGetValue(edge.TargetNode.Id, out var target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isMuted = source.Role == GraphPartitionNodeRole.Anchor.ToApiValue() || target.Role == GraphPartitionNodeRole.Anchor.ToApiValue();
|
||||
var path = BuildEdgePath(edge, source, target, layout);
|
||||
var stroke = isMuted ? "#94A3B8" : theme.EdgeAccent;
|
||||
var opacity = isMuted ? "0.28" : "0.52";
|
||||
var width = isMuted ? "1.3" : "1.8";
|
||||
var marker = isMuted ? "url(#arrow-muted)" : "url(#arrow-accent)";
|
||||
|
||||
document.AppendLine(
|
||||
$""" <path id="{ToDomId("edge", edge.SourceNode.Id, edge.TargetNode.Id)}" class="edge {(isMuted ? "edge-muted" : "edge-primary")}" data-source-id="{Escape(edge.SourceNode.Id)}" data-target-id="{Escape(edge.TargetNode.Id)}" d="{path}" stroke="{stroke}" stroke-opacity="{opacity}" stroke-width="{width}" stroke-linecap="round" stroke-linejoin="round" marker-end="{marker}" />""");
|
||||
}
|
||||
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
private static void AppendNodes(StringBuilder document, IReadOnlyList<NodeVisual> nodes, Theme theme)
|
||||
{
|
||||
document.AppendLine(""" <g id="nodes">""");
|
||||
|
||||
foreach (var node in nodes.OrderBy(node => node.Role == GraphPartitionNodeRole.Anchor.ToApiValue()).ThenBy(node => node.Depth).ThenBy(node => node.Id, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var titleLines = WrapLabel(node.Title, node.TitleCharacterLimit, 3);
|
||||
var titleY = node.Y + 31;
|
||||
var titleX = node.X + 18;
|
||||
var bandHeight = Math.Min(10, Math.Max(6, node.Height * 0.16));
|
||||
var badgeVisible = node.DegreeBadgeVisible;
|
||||
var badgeRadius = 12.5;
|
||||
var badgeCx = node.X + node.Width - 18;
|
||||
var badgeCy = node.Y + 18;
|
||||
var groupFilter = node.IsEntry ? "url(#entry-glow)" : "url(#node-glow)";
|
||||
|
||||
document.AppendLine(
|
||||
$""" <g id="{ToDomId("node", node.Id)}" class="node node-{Escape(node.Role)}" data-node-id="{Escape(node.Id)}" data-depth="{node.Depth}" data-role="{Escape(node.Role)}" data-degree="{node.Degree}" filter="{groupFilter}">""");
|
||||
|
||||
if (node.IsEntry)
|
||||
{
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(node.X - 6)}" y="{Fmt(node.Y - 6)}" width="{Fmt(node.Width + 12)}" height="{Fmt(node.Height + 12)}" rx="{Fmt(node.CornerRadius + 6)}" fill="none" stroke="#FDE68A" stroke-opacity="0.88" stroke-width="2.2" />""");
|
||||
}
|
||||
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(node.X)}" y="{Fmt(node.Y)}" width="{Fmt(node.Width)}" height="{Fmt(node.Height)}" rx="{Fmt(node.CornerRadius)}" fill="{node.PanelFill}" fill-opacity="{node.PanelOpacity}" stroke="{node.Stroke}" stroke-opacity="0.95" stroke-width="{node.StrokeWidth}" />""");
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(node.X + 1.5)}" y="{Fmt(node.Y + 1.5)}" width="{Fmt(node.Width - 3)}" height="{Fmt(bandHeight)}" rx="{Fmt(node.CornerRadius - 1.5)}" fill="{node.Accent}" fill-opacity="0.7" />""");
|
||||
document.AppendLine(
|
||||
$""" <circle cx="{Fmt(node.X + 18)}" cy="{Fmt(node.Y + 20)}" r="9" fill="{node.Accent}" fill-opacity="0.94" stroke="{node.IconStroke}" stroke-opacity="0.9" stroke-width="1.2" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(node.X + 18)}" y="{Fmt(node.Y + 24.5)}" text-anchor="middle" fill="{node.IconText}" font-family="{theme.FontFamily}" font-size="9.5" font-weight="700">{Escape(node.IconTextValue)}</text>""");
|
||||
|
||||
if (badgeVisible)
|
||||
{
|
||||
document.AppendLine(
|
||||
$""" <circle cx="{Fmt(badgeCx)}" cy="{Fmt(badgeCy)}" r="{Fmt(badgeRadius)}" fill="{theme.BadgeFill}" fill-opacity="0.95" stroke="{node.Accent}" stroke-width="1.4" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(badgeCx)}" y="{Fmt(badgeCy + 4.5)}" text-anchor="middle" fill="{theme.BadgeText}" font-family="{theme.FontFamily}" font-size="11" font-weight="700">{node.Degree}</text>""");
|
||||
}
|
||||
|
||||
for (var i = 0; i < titleLines.Count; i++)
|
||||
{
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(titleX)}" y="{Fmt(titleY + i * 16)}" fill="{node.TitleColor}" font-family="{theme.FontFamily}" font-size="{Fmt(node.TitleFontSize)}" font-weight="600">{Escape(titleLines[i])}</text>""");
|
||||
}
|
||||
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(titleX)}" y="{Fmt(node.Y + node.Height - 16)}" fill="{theme.MutedText}" fill-opacity="0.86" font-family="{theme.FontFamily}" font-size="10.5" letter-spacing="0.12em">{Escape(node.Subtitle)}</text>""");
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
private static void AppendLegend(StringBuilder document, LayoutReport report, LayoutFrame layout, Theme theme)
|
||||
{
|
||||
document.AppendLine(""" <g id="legend">""");
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(layout.SidebarX)}" y="{Fmt(layout.GraphAreaY)}" width="{Fmt(layout.SidebarWidth)}" height="{Fmt(layout.GraphAreaHeight)}" rx="28" fill="{theme.SidebarFill}" fill-opacity="0.88" stroke="{theme.SidebarStroke}" stroke-opacity="0.65" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.SidebarX + 24)}" y="{Fmt(layout.GraphAreaY + 34)}" fill="{theme.HeaderText}" font-family="{theme.FontFamily}" font-size="20" font-weight="700">Render Notes</text>""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.SidebarX + 24)}" y="{Fmt(layout.GraphAreaY + 56)}" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="11.5">custom SVG over MSAGL coordinates</text>""");
|
||||
|
||||
AppendLegendStat(document, layout.SidebarX + 24, layout.GraphAreaY + 96, "Nodes", report.NodeCount.ToString(CultureInfo.InvariantCulture), theme);
|
||||
AppendLegendStat(document, layout.SidebarX + 24, layout.GraphAreaY + 142, "Edges", report.EdgeCount.ToString(CultureInfo.InvariantCulture), theme);
|
||||
AppendLegendStat(document, layout.SidebarX + 24, layout.GraphAreaY + 188, "Layout", report.LayoutMode.ToUpperInvariant(), theme);
|
||||
|
||||
var swatchY = layout.GraphAreaY + 256;
|
||||
AppendLegendSwatch(document, layout.SidebarX + 24, swatchY, theme.EntryAccent, "Entry", "seed or canonical root", theme);
|
||||
AppendLegendSwatch(document, layout.SidebarX + 24, swatchY + 48, theme.GetDepthAccent(2, GraphPartitionNodeRole.Primary.ToApiValue()), "Primary", "main partition member", theme);
|
||||
AppendLegendSwatch(document, layout.SidebarX + 24, swatchY + 96, "#94A3B8", "Anchor", "cross-family tether node", theme);
|
||||
AppendLegendSwatch(document, layout.SidebarX + 24, swatchY + 144, theme.BadgeFill, "Degree Badge", "total edge count on node", theme);
|
||||
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.SidebarX + 24)}" y="{Fmt(layout.GraphAreaY + layout.GraphAreaHeight - 58)}" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="11">The raw MSAGL SVG still exists. This one is just trying harder.</text>""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(layout.SidebarX + 24)}" y="{Fmt(layout.GraphAreaY + layout.GraphAreaHeight - 34)}" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="11">Use the benchmark to decide whether it earned the extra attitude.</text>""");
|
||||
document.AppendLine(" </g>");
|
||||
}
|
||||
|
||||
private static void AppendLegendStat(StringBuilder document, double x, double y, string label, string value, Theme theme)
|
||||
{
|
||||
document.AppendLine(
|
||||
$""" <rect x="{Fmt(x)}" y="{Fmt(y)}" width="232" height="34" rx="17" fill="{theme.StatFill}" fill-opacity="0.92" stroke="{theme.StatStroke}" stroke-opacity="0.5" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(x + 16)}" y="{Fmt(y + 22)}" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="11" letter-spacing="0.16em">{Escape(label.ToUpperInvariant())}</text>""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(x + 216)}" y="{Fmt(y + 23)}" text-anchor="end" fill="{theme.HeaderText}" font-family="{theme.FontFamily}" font-size="14" font-weight="700">{Escape(value)}</text>""");
|
||||
}
|
||||
|
||||
private static void AppendLegendSwatch(StringBuilder document, double x, double y, string color, string label, string note, Theme theme)
|
||||
{
|
||||
document.AppendLine(
|
||||
$""" <circle cx="{Fmt(x + 10)}" cy="{Fmt(y)}" r="8" fill="{color}" stroke="{theme.SidebarStroke}" stroke-opacity="0.35" />""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(x + 28)}" y="{Fmt(y + 4)}" fill="{theme.HeaderText}" font-family="{theme.FontFamily}" font-size="13" font-weight="600">{Escape(label)}</text>""");
|
||||
document.AppendLine(
|
||||
$""" <text x="{Fmt(x + 28)}" y="{Fmt(y + 20)}" fill="{theme.MutedText}" font-family="{theme.FontFamily}" font-size="11">{Escape(note)}</text>""");
|
||||
}
|
||||
|
||||
private static string BuildEdgePath(Edge edge, NodeVisual source, NodeVisual target, LayoutFrame layout)
|
||||
{
|
||||
var curve = edge.EdgeCurve ?? edge.GeometryEdge?.Curve;
|
||||
if (curve is not null)
|
||||
{
|
||||
return SampleCurvePath(curve, layout);
|
||||
}
|
||||
|
||||
return BuildFallbackEdgePath(source, target);
|
||||
}
|
||||
|
||||
private static string SampleCurvePath(ICurve curve, LayoutFrame layout)
|
||||
{
|
||||
var sampleCount = Math.Clamp((int)Math.Ceiling(curve.Length / 28.0), 8, 64);
|
||||
var parameters = Enumerable.Range(0, sampleCount + 1)
|
||||
.Select(index => curve.ParStart + ((curve.ParEnd - curve.ParStart) * index / sampleCount))
|
||||
.ToArray();
|
||||
var points = parameters
|
||||
.Select(parameter => TransformPoint(curve[parameter], layout))
|
||||
.ToArray();
|
||||
|
||||
if (points.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append($"M {Fmt(points[0].X)} {Fmt(points[0].Y)}");
|
||||
for (var i = 1; i < points.Length; i++)
|
||||
{
|
||||
builder.Append($" L {Fmt(points[i].X)} {Fmt(points[i].Y)}");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildFallbackEdgePath(NodeVisual source, NodeVisual target)
|
||||
{
|
||||
var dx = target.CenterX - source.CenterX;
|
||||
var dy = target.CenterY - source.CenterY;
|
||||
var start = ResolveRectAnchor(source.CenterX, source.CenterY, source.Width / 2.0, source.Height / 2.0, dx, dy);
|
||||
var end = ResolveRectAnchor(target.CenterX, target.CenterY, target.Width / 2.0, target.Height / 2.0, -dx, -dy);
|
||||
|
||||
var controlRatio = Math.Abs(dx) > Math.Abs(dy) ? 0.34 : 0.18;
|
||||
var c1x = start.X + dx * controlRatio;
|
||||
var c1y = start.Y + dy * 0.08;
|
||||
var c2x = end.X - dx * controlRatio;
|
||||
var c2y = end.Y - dy * 0.08;
|
||||
|
||||
return $"M {Fmt(start.X)} {Fmt(start.Y)} C {Fmt(c1x)} {Fmt(c1y)}, {Fmt(c2x)} {Fmt(c2y)}, {Fmt(end.X)} {Fmt(end.Y)}";
|
||||
}
|
||||
|
||||
private static (double X, double Y) TransformPoint(Microsoft.Msagl.Core.Geometry.Point point, LayoutFrame layout) =>
|
||||
(
|
||||
X: layout.GraphAreaX + (point.X - layout.MinLeft),
|
||||
Y: layout.GraphAreaY + (layout.MaxTop - point.Y)
|
||||
);
|
||||
|
||||
private static (double X, double Y) ResolveRectAnchor(double centerX, double centerY, double halfWidth, double halfHeight, double dx, double dy)
|
||||
{
|
||||
if (Math.Abs(dx) < 0.001 && Math.Abs(dy) < 0.001)
|
||||
{
|
||||
return (centerX, centerY);
|
||||
}
|
||||
|
||||
var scale = 1.0 / Math.Max(Math.Abs(dx) / Math.Max(halfWidth, 1), Math.Abs(dy) / Math.Max(halfHeight, 1));
|
||||
return (centerX + dx * scale, centerY + dy * scale);
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> CalculateDegrees(LayoutReport report)
|
||||
{
|
||||
var degrees = report.Nodes.ToDictionary(node => node.Id, _ => 0, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var edge in report.Edges)
|
||||
{
|
||||
degrees[edge.SourceId] = degrees.GetValueOrDefault(edge.SourceId) + 1;
|
||||
degrees[edge.TargetId] = degrees.GetValueOrDefault(edge.TargetId) + 1;
|
||||
}
|
||||
|
||||
return degrees;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> WrapLabel(string label, int maxChars, int maxLines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
return ["Untitled"];
|
||||
}
|
||||
|
||||
var tokens = label.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length == 0)
|
||||
{
|
||||
return [label];
|
||||
}
|
||||
|
||||
var lines = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (token.Length > maxChars)
|
||||
{
|
||||
Flush();
|
||||
foreach (var chunk in SplitLongToken(token, maxChars))
|
||||
{
|
||||
lines.Add(chunk);
|
||||
if (lines.Count == maxLines)
|
||||
{
|
||||
lines[^1] = Ellipsize(lines[^1], maxChars);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateLength = current.Length == 0 ? token.Length : current.Length + 1 + token.Length;
|
||||
if (candidateLength > maxChars)
|
||||
{
|
||||
Flush();
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
current.Append(' ');
|
||||
}
|
||||
|
||||
current.Append(token);
|
||||
}
|
||||
|
||||
Flush();
|
||||
|
||||
if (lines.Count > maxLines)
|
||||
{
|
||||
lines = lines.Take(maxLines).ToList();
|
||||
}
|
||||
|
||||
if (lines.Count == maxLines && tokens.Length > 0)
|
||||
{
|
||||
lines[^1] = Ellipsize(lines[^1], maxChars);
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (current.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lines.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitLongToken(string token, int maxChars)
|
||||
{
|
||||
for (var i = 0; i < token.Length; i += maxChars)
|
||||
{
|
||||
var length = Math.Min(maxChars, token.Length - i);
|
||||
yield return token.Substring(i, length);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Ellipsize(string value, int maxChars)
|
||||
{
|
||||
if (value.Length <= maxChars)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (maxChars <= 1)
|
||||
{
|
||||
return "…";
|
||||
}
|
||||
|
||||
return $"{value[..(maxChars - 1)]}…";
|
||||
}
|
||||
|
||||
private static string Fmt(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Escape(string value) => WebUtility.HtmlEncode(value);
|
||||
|
||||
private static string ToDomId(string prefix, params string[] values)
|
||||
{
|
||||
var joined = string.Join("-", values);
|
||||
var normalized = new string(joined
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-')
|
||||
.ToArray());
|
||||
while (normalized.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return $"{prefix}-{normalized.Trim('-')}";
|
||||
}
|
||||
|
||||
private sealed record LayoutFrame(
|
||||
double CanvasWidth,
|
||||
double CanvasHeight,
|
||||
double GraphAreaX,
|
||||
double GraphAreaY,
|
||||
double GraphAreaWidth,
|
||||
double GraphAreaHeight,
|
||||
double SidebarX,
|
||||
double SidebarWidth,
|
||||
double MinLeft,
|
||||
double MaxTop)
|
||||
{
|
||||
public static LayoutFrame Create(LayoutReport report)
|
||||
{
|
||||
const double outerPadding = 54;
|
||||
const double headerHeight = 108;
|
||||
const double footerHeight = 38;
|
||||
const double sidebarWidth = 280;
|
||||
const double sidebarGap = 30;
|
||||
|
||||
var minLeft = report.Nodes.Min(node => node.Bounds.Left);
|
||||
var maxRight = report.Nodes.Max(node => node.Bounds.Left + node.Bounds.Width);
|
||||
var maxTop = report.Nodes.Max(node => node.Bounds.Bottom + node.Bounds.Height);
|
||||
var minBottom = report.Nodes.Min(node => node.Bounds.Bottom);
|
||||
|
||||
var graphWidth = Math.Max(640, maxRight - minLeft);
|
||||
var graphHeight = Math.Max(520, maxTop - minBottom);
|
||||
var graphAreaX = outerPadding;
|
||||
var graphAreaY = headerHeight;
|
||||
var canvasWidth = outerPadding * 2 + graphWidth + sidebarGap + sidebarWidth;
|
||||
var canvasHeight = graphAreaY + graphHeight + outerPadding + footerHeight;
|
||||
var sidebarX = graphAreaX + graphWidth + sidebarGap;
|
||||
|
||||
return new LayoutFrame(
|
||||
CanvasWidth: canvasWidth,
|
||||
CanvasHeight: canvasHeight,
|
||||
GraphAreaX: graphAreaX,
|
||||
GraphAreaY: graphAreaY,
|
||||
GraphAreaWidth: graphWidth,
|
||||
GraphAreaHeight: graphHeight,
|
||||
SidebarX: sidebarX,
|
||||
SidebarWidth: sidebarWidth,
|
||||
MinLeft: minLeft,
|
||||
MaxTop: maxTop);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record NodeVisual(
|
||||
string Id,
|
||||
string Title,
|
||||
int Depth,
|
||||
string Role,
|
||||
bool IsEntry,
|
||||
int Degree,
|
||||
bool DegreeBadgeVisible,
|
||||
double X,
|
||||
double Y,
|
||||
double Width,
|
||||
double Height,
|
||||
double CenterX,
|
||||
double CenterY,
|
||||
double CornerRadius,
|
||||
double StrokeWidth,
|
||||
double TitleFontSize,
|
||||
int TitleCharacterLimit,
|
||||
string Subtitle,
|
||||
string PanelFill,
|
||||
string PanelOpacity,
|
||||
string Stroke,
|
||||
string Accent,
|
||||
string TitleColor,
|
||||
string IconStroke,
|
||||
string IconText,
|
||||
string IconTextValue)
|
||||
{
|
||||
public static NodeVisual Create(LayoutNodeRecord node, LayoutFrame layout, Theme theme, int degree)
|
||||
{
|
||||
var x = layout.GraphAreaX + (node.Bounds.Left - layout.MinLeft);
|
||||
var y = layout.GraphAreaY + (layout.MaxTop - (node.Bounds.Bottom + node.Bounds.Height));
|
||||
var accent = theme.GetDepthAccent(node.Depth, node.Role, node.IsEntry);
|
||||
var isAnchor = node.Role == GraphPartitionNodeRole.Anchor.ToApiValue();
|
||||
var subtitle = node.IsEntry
|
||||
? "ENTRY NODE"
|
||||
: isAnchor
|
||||
? "ANCHOR"
|
||||
: $"DEPTH {Math.Max(node.Depth, 0)}";
|
||||
var characterLimit = Math.Clamp((int)Math.Floor((node.Bounds.Width - 38) / 7.15), 12, 28);
|
||||
var degreeBadgeVisible = degree >= 3 || node.IsEntry;
|
||||
|
||||
return new NodeVisual(
|
||||
Id: node.Id,
|
||||
Title: node.Title,
|
||||
Depth: node.Depth,
|
||||
Role: node.Role,
|
||||
IsEntry: node.IsEntry,
|
||||
Degree: degree,
|
||||
DegreeBadgeVisible: degreeBadgeVisible,
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: node.Bounds.Width,
|
||||
Height: node.Bounds.Height,
|
||||
CenterX: x + node.Bounds.Width / 2.0,
|
||||
CenterY: y + node.Bounds.Height / 2.0,
|
||||
CornerRadius: isAnchor ? 18 : 22,
|
||||
StrokeWidth: node.IsEntry ? 2.1 : isAnchor ? 1.25 : 1.55,
|
||||
TitleFontSize: isAnchor ? 11.5 : node.IsEntry ? 15 : 13.5,
|
||||
TitleCharacterLimit: characterLimit,
|
||||
Subtitle: subtitle,
|
||||
PanelFill: isAnchor ? "#111827" : "#08111F",
|
||||
PanelOpacity: isAnchor ? "0.78" : "0.88",
|
||||
Stroke: node.IsEntry ? theme.EntryAccent : isAnchor ? "#94A3B8" : accent,
|
||||
Accent: node.IsEntry ? theme.EntryAccent : accent,
|
||||
TitleColor: node.IsEntry ? "#FFF8E8" : "#F8FAFC",
|
||||
IconStroke: isAnchor ? "#E2E8F0" : "#FFFFFF",
|
||||
IconText: isAnchor ? "#0F172A" : "#08111F",
|
||||
IconTextValue: node.IsEntry ? "E" : isAnchor ? "A" : theme.IconLetter);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Theme(
|
||||
string FontFamily,
|
||||
string BackgroundStart,
|
||||
string BackgroundMid,
|
||||
string BackgroundEnd,
|
||||
string GraphPanelFill,
|
||||
string GraphPanelStroke,
|
||||
string HeaderFill,
|
||||
string HeaderStroke,
|
||||
string HeaderText,
|
||||
string MutedText,
|
||||
string GlowColor,
|
||||
string SidebarFill,
|
||||
string SidebarStroke,
|
||||
string StatFill,
|
||||
string StatStroke,
|
||||
string PillFill,
|
||||
string PillStroke,
|
||||
string BadgeFill,
|
||||
string BadgeText,
|
||||
string EdgeAccent,
|
||||
string EntryAccent,
|
||||
string IconLetter,
|
||||
IReadOnlyList<string> PrimaryDepthPalette)
|
||||
{
|
||||
public static Theme For(string graphKey)
|
||||
{
|
||||
if (graphKey.Equals("control-flow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Theme(
|
||||
FontFamily: "Bahnschrift, Segoe UI, sans-serif",
|
||||
BackgroundStart: "#070C15",
|
||||
BackgroundMid: "#161022",
|
||||
BackgroundEnd: "#291017",
|
||||
GraphPanelFill: "#0A1020",
|
||||
GraphPanelStroke: "#F97316",
|
||||
HeaderFill: "#120E1C",
|
||||
HeaderStroke: "#FB7185",
|
||||
HeaderText: "#FFF7ED",
|
||||
MutedText: "#FDBA74",
|
||||
GlowColor: "#FB7185",
|
||||
SidebarFill: "#0B1120",
|
||||
SidebarStroke: "#F97316",
|
||||
StatFill: "#111827",
|
||||
StatStroke: "#FB7185",
|
||||
PillFill: "#2B1320",
|
||||
PillStroke: "#FB7185",
|
||||
BadgeFill: "#180E1D",
|
||||
BadgeText: "#FFF7ED",
|
||||
EdgeAccent: "#FB7185",
|
||||
EntryAccent: "#FDE68A",
|
||||
IconLetter: "C",
|
||||
PrimaryDepthPalette:
|
||||
[
|
||||
"#FB7185",
|
||||
"#F97316",
|
||||
"#F59E0B",
|
||||
"#FDE68A",
|
||||
"#67E8F9",
|
||||
"#A78BFA"
|
||||
]);
|
||||
}
|
||||
|
||||
return new Theme(
|
||||
FontFamily: "Bahnschrift, Segoe UI, sans-serif",
|
||||
BackgroundStart: "#061019",
|
||||
BackgroundMid: "#0B1529",
|
||||
BackgroundEnd: "#111827",
|
||||
GraphPanelFill: "#08131F",
|
||||
GraphPanelStroke: "#22D3EE",
|
||||
HeaderFill: "#091525",
|
||||
HeaderStroke: "#38BDF8",
|
||||
HeaderText: "#F8FAFC",
|
||||
MutedText: "#A5F3FC",
|
||||
GlowColor: "#22D3EE",
|
||||
SidebarFill: "#09131F",
|
||||
SidebarStroke: "#22D3EE",
|
||||
StatFill: "#0F172A",
|
||||
StatStroke: "#38BDF8",
|
||||
PillFill: "#10263C",
|
||||
PillStroke: "#22D3EE",
|
||||
BadgeFill: "#0B1220",
|
||||
BadgeText: "#F8FAFC",
|
||||
EdgeAccent: "#22D3EE",
|
||||
EntryAccent: "#FDE68A",
|
||||
IconLetter: "S",
|
||||
PrimaryDepthPalette:
|
||||
[
|
||||
"#38BDF8",
|
||||
"#22D3EE",
|
||||
"#2DD4BF",
|
||||
"#86EFAC",
|
||||
"#C4B5FD",
|
||||
"#F9A8D4"
|
||||
]);
|
||||
}
|
||||
|
||||
public string GetDepthAccent(int depth, string role, bool isEntry = false)
|
||||
{
|
||||
if (isEntry)
|
||||
{
|
||||
return EntryAccent;
|
||||
}
|
||||
|
||||
if (role == GraphPartitionNodeRole.Anchor.ToApiValue())
|
||||
{
|
||||
return "#94A3B8";
|
||||
}
|
||||
|
||||
if (PrimaryDepthPalette.Count == 0)
|
||||
{
|
||||
return EdgeAccent;
|
||||
}
|
||||
|
||||
if (depth < 0)
|
||||
{
|
||||
return PrimaryDepthPalette[^1];
|
||||
}
|
||||
|
||||
return PrimaryDepthPalette[depth % PrimaryDepthPalette.Count];
|
||||
}
|
||||
}
|
||||
}
|
||||
153
TUNING.md
Normal file
153
TUNING.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Graph Reasoning Tuning
|
||||
|
||||
Use this to steer assistant output when discussing graph layouts, graph understanding, and multimodal reasoning around visualized note graphs.
|
||||
|
||||
## Goal
|
||||
|
||||
Make answers more useful than "force layouts are pretty" and less stupid than "just describe the image in text." The assistant should treat graph visuals as real signal, but not confuse the layout with the underlying truth.
|
||||
|
||||
## Core Positions
|
||||
|
||||
### 1. Force-directed layouts are valid, but not authoritative
|
||||
|
||||
- Do not dunk on Obsidian's graph or Kamada-Kawai-style layouts.
|
||||
- Treat force-directed layouts as good for:
|
||||
- visual balance
|
||||
- surfacing hubs and clusters
|
||||
- giving a graph an intuitive overall shape
|
||||
- Be explicit that they are worse at:
|
||||
- preserving a stable mental map over time
|
||||
- showing direction, hierarchy, or causality
|
||||
- making semantically important structure obvious
|
||||
|
||||
### 2. Visual layout matters
|
||||
|
||||
- Do not imply that an image is reduced to a plain text description and then reasoned over from there alone.
|
||||
- Say, at a high level, that visual input provides direct signal about:
|
||||
- spatial relationships
|
||||
- grouping and density
|
||||
- relative position
|
||||
- crossings
|
||||
- hubs, bridges, isolates, and clusters
|
||||
- When asked whether graph visuals help, answer yes plainly.
|
||||
|
||||
### 3. Visual plus structured beats either alone
|
||||
|
||||
- Prefer `image + graph data` as the ideal setup.
|
||||
- Describe the division of labor clearly:
|
||||
- visual layout gives spatial intuition
|
||||
- machine-readable graph data gives exact reasoning
|
||||
- A screenshot helps identify shape and suspicious regions.
|
||||
- Node and edge data help verify exact neighbors, paths, labels, and weights.
|
||||
|
||||
### 4. One layout is a view, not the truth
|
||||
|
||||
- Encourage multiple views when the task is serious:
|
||||
- force-directed for shape
|
||||
- layered or directional for flow
|
||||
- local neighborhood views for focused work
|
||||
- filtered or clustered views for dense graphs
|
||||
- If a layout option exists in MSAGL, present it as one tool in the kit, not the final answer to meaning.
|
||||
- If asked specifically about MSAGL, note that `mds` is the closest built-in approximation to a Kamada-Kawai or Obsidian-like organic layout, but not the same named algorithm.
|
||||
|
||||
### 5. Stability is part of usefulness
|
||||
|
||||
- Prefer and praise:
|
||||
- deterministic layout
|
||||
- stable coordinates
|
||||
- pinning or anchoring
|
||||
- semantic clustering
|
||||
- focused subgraphs
|
||||
- Explain that these make the visual arrangement itself informative instead of decorative drift.
|
||||
|
||||
### 6. Be honest about vision internals
|
||||
|
||||
- It is fine to explain vision processing only in broad strokes.
|
||||
- It is not fine to invent exact internal tensor schemas, symbolic encodings, or inspectable layer-by-layer mechanics.
|
||||
- Good answer shape:
|
||||
- "I can explain the useful truth at a high level."
|
||||
- "I use visual features directly for spatial and grouping signal."
|
||||
- "I cannot provide an exact low-level internal representation."
|
||||
|
||||
## Response Policy
|
||||
|
||||
When the user asks whether a graph layout is useful:
|
||||
|
||||
- validate the useful part first
|
||||
- distinguish visual exploration from exact reasoning
|
||||
- recommend the right layout for the job instead of naming one winner
|
||||
|
||||
When the user asks whether visuals help an LLM:
|
||||
|
||||
- answer yes
|
||||
- name the kinds of structure the model can often use visually
|
||||
- immediately add that structured graph data improves precision
|
||||
|
||||
When the user asks for the best interface:
|
||||
|
||||
- recommend:
|
||||
1. rendered graph
|
||||
2. machine-readable node/edge payload
|
||||
3. focused subgraphs for dense regions
|
||||
4. stable or deterministic layout when comparisons matter
|
||||
|
||||
When the user asks about Obsidian or spring layouts:
|
||||
|
||||
- do not sneer
|
||||
- say they are good for wandering and seeing global shape
|
||||
- say they are weaker for hierarchy, causality, and stable comparison
|
||||
|
||||
When the user asks how vision is represented:
|
||||
|
||||
- answer in high-level capability terms
|
||||
- do not fabricate internal implementation detail
|
||||
|
||||
## Do / Don't
|
||||
|
||||
Do:
|
||||
|
||||
- say that layout gives real signal
|
||||
- separate "looks good" from "supports reasoning well"
|
||||
- suggest complementary views
|
||||
- recommend combining image inspection with structured graph queries
|
||||
- talk concretely about hubs, clusters, bridges, isolates, crossings, flow, and stability
|
||||
|
||||
Don't:
|
||||
|
||||
- treat a spring layout as semantic truth
|
||||
- claim that text description alone preserves the important visual information
|
||||
- imply the model only sees "a paragraph about the picture"
|
||||
- invent internal model mechanics you cannot actually inspect
|
||||
- dismiss visual graphs just because structured data exists
|
||||
|
||||
## Preferred Framing
|
||||
|
||||
Good:
|
||||
|
||||
- "That layout is useful for seeing shape, but not for all reasoning tasks."
|
||||
- "The sweet spot is the rendered graph plus the underlying graph data."
|
||||
- "Use force layout for overview, then switch to layered or local views when you need exact structure."
|
||||
- "Stable layout matters because position drift destroys comparison."
|
||||
|
||||
Bad:
|
||||
|
||||
- "Spring layouts are fake and useless."
|
||||
- "Just describe the graph in words."
|
||||
- "The image is only converted into text internally."
|
||||
- "This layout reveals the true meaning of the graph."
|
||||
|
||||
## Suggested Prompt Block
|
||||
|
||||
Use or adapt this block in prompts, system guidance, or agent notes:
|
||||
|
||||
> When discussing graph visualization, treat layouts as analytical tools rather than truth. Force-directed layouts are good for shape, clusters, and hubs, but weaker for direction, hierarchy, causality, and stable comparison. Do not dismiss visual layouts, and do not reduce them to mere text descriptions. Prefer recommending visual plus structured graph data together: the visual view provides spatial intuition, while the graph payload enables exact reasoning about paths, neighbors, weights, and labels. Favor deterministic layout, stable coordinates, semantic clustering, and focused subgraphs when usefulness matters. If asked about visual processing, explain only at a high level and do not invent exact internal representations.
|
||||
|
||||
## Short Version
|
||||
|
||||
If you need the whole thing in one line:
|
||||
|
||||
- layouts are useful views, not truth
|
||||
- visuals matter
|
||||
- structured graph data matters
|
||||
- visual plus structured is the sweet spot
|
||||
- stability and focus views make the graph genuinely useful
|
||||
87
benchmarks/README.md
Normal file
87
benchmarks/README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# EpiphanyGraph Benchmarks
|
||||
|
||||
These benchmarks measure whether a rendered graph helps a vision-capable agent recover useful graph truth.
|
||||
|
||||
This is not a beauty contest. If a candidate looks cool but does not improve correct answers, it is decorative trash in a nice hat.
|
||||
|
||||
## Local Workflow
|
||||
|
||||
Generate a benchmark run:
|
||||
|
||||
```powershell
|
||||
.\scripts\New-BenchmarkRun.ps1
|
||||
```
|
||||
|
||||
The script creates a timestamped run under `out/benchmarks/`, renders every candidate from the benchmark spec, and writes a review sheet for each candidate.
|
||||
|
||||
Review one candidate at a time:
|
||||
|
||||
1. Open the candidate PNG listed in `answer-sheet.md`.
|
||||
2. Answer the blind probe questions before reading any gold data.
|
||||
3. Treat the SVG as vector reference only, not as the primary image input.
|
||||
4. Use the layout JSON only when a probe says JSON is allowed.
|
||||
5. Copy `scores.template.json` to `scores.json`.
|
||||
6. Fill in the per-probe scores after comparing your answers against the run-level `gold.json`.
|
||||
|
||||
Each candidate render directory may also contain sibling `*.msagl.svg` and `*.rich.svg` files. During blind review, ignore those. The candidate under test is always the primary `source-tree.svg` / `control-flow.svg` pair plus their PNG previews.
|
||||
|
||||
Aggregate manual scores:
|
||||
|
||||
```powershell
|
||||
.\scripts\Measure-BenchmarkRun.ps1 -RunDir .\out\benchmarks\aetheria-source-tree-map\<run-id>
|
||||
```
|
||||
|
||||
## Benchmark Shape
|
||||
|
||||
Each benchmark contains:
|
||||
|
||||
- `*.benchmark.json`: corpus path, graph entry note, and render candidates
|
||||
- `questions.json`: blind probes shown to the evaluator
|
||||
- `gold.json`: expected answers and scoring criteria
|
||||
|
||||
The generated run contains:
|
||||
|
||||
- `run-manifest.json`: run metadata and candidate outputs
|
||||
- `gold.json`: scoring key, kept out of per-candidate answer sheets
|
||||
- `candidates/<candidate-id>/render/`: EpiphanyGraph outputs
|
||||
- `candidates/<candidate-id>/render/*.png`: raster previews for image probes
|
||||
- `candidates/<candidate-id>/render/*.msagl.svg`: preserved stock baseline output
|
||||
- `candidates/<candidate-id>/render/*.rich.svg`: preserved custom renderer output
|
||||
- `candidates/<candidate-id>/answer-sheet.md`: blind review sheet
|
||||
- `candidates/<candidate-id>/scores.template.json`: manual scoring skeleton
|
||||
|
||||
## Input Modes
|
||||
|
||||
Probe questions declare allowed inputs.
|
||||
|
||||
- `image`: answer from the rendered SVG only
|
||||
- `json`: inspect layout JSON or graph data
|
||||
- `image+json`: use the visual first, then verify against JSON
|
||||
|
||||
The useful benchmark result is the delta between these modes. If the image does not help the evaluator answer graph questions better, the render is just wallpaper with arrows.
|
||||
|
||||
## Current Corpus
|
||||
|
||||
`aetheria-economy/source-tree-map.benchmark.json` targets:
|
||||
|
||||
```text
|
||||
E:\Projects\Aetheria-Economy\Aetheria\Source Tree Map
|
||||
```
|
||||
|
||||
It exercises the current EpiphanyGraph partition output:
|
||||
|
||||
- `source-tree.svg` and `source-tree.json`
|
||||
- `control-flow.svg` and `control-flow.json`
|
||||
- `cross-links.json`
|
||||
|
||||
## Scoring
|
||||
|
||||
Start with simple manual scoring. Automation can come later after the probes stop wriggling.
|
||||
|
||||
Use:
|
||||
|
||||
- `1.0`: correct and specific
|
||||
- `0.5`: partially correct, vague, or missing one key element
|
||||
- `0.0`: wrong, hallucinated, or unsupported by the allowed inputs
|
||||
|
||||
Diagnostic probes may have `maxScore` set to `0`. They are useful notes, not leaderboard bait.
|
||||
125
benchmarks/aetheria-economy/gold.json
Normal file
125
benchmarks/aetheria-economy/gold.json
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
{
|
||||
"benchmark": "aetheria-source-tree-map",
|
||||
"sourceMaterial": [
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Source Tree Map.md",
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Control Flow Map.md",
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Economy Server Control Flow.md",
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Unity Client\\Scripts\\Gameplay Session Control Flow.md",
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Unity Client\\Scripts\\Simulation Tick Control Flow.md",
|
||||
"E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map\\Unity Client\\Scripts\\Player Input and Weapons Control Flow.md"
|
||||
],
|
||||
"probes": {
|
||||
"source-top-split": {
|
||||
"expected": [
|
||||
"Unity Client",
|
||||
"Economy Server",
|
||||
"Data Audio and Authoring",
|
||||
"Third Party and Config"
|
||||
],
|
||||
"scoring": "Full credit for all four major branches. Half credit for three correct branches or for substituting a direct child note that clearly belongs to one branch."
|
||||
},
|
||||
"source-authoring-cluster": {
|
||||
"expected": [
|
||||
"Data Audio and Authoring",
|
||||
"GameData",
|
||||
"Wwise Project",
|
||||
"Asset Sources and Root Artifacts"
|
||||
],
|
||||
"scoring": "Full credit for naming Data Audio and Authoring and at least one correct child. Half credit for naming only a child without the parent branch."
|
||||
},
|
||||
"source-rendering-path": {
|
||||
"expected": [
|
||||
"Unity Client",
|
||||
"Rendering and Content",
|
||||
"Shaders and Compute"
|
||||
],
|
||||
"scoring": "Full credit for the complete path. Half credit for identifying Shaders and Compute but missing one parent."
|
||||
},
|
||||
"source-shared-simulation-hub": {
|
||||
"expected": {
|
||||
"node": "Unity Client/Scripts/Shared Simulation",
|
||||
"degreeRank": "Major shared-code hub in the source-tree baseline JSON. Unity Client/Scripts has the highest non-root total degree, but Shared Simulation is the key shared runtime hub.",
|
||||
"baselineDegree": {
|
||||
"in": 8,
|
||||
"out": 6,
|
||||
"total": 14
|
||||
}
|
||||
},
|
||||
"scoring": "Full credit for Shared Simulation with JSON degree support. Half credit for Shared Simulation Core or Shared Behaviors if the answer explains the shared-code hub correctly but misses the exact highest-degree node."
|
||||
},
|
||||
"source-generated-config-branch": {
|
||||
"expected": [
|
||||
"Third Party and Config",
|
||||
"Project Config Shared Math and Generated State"
|
||||
],
|
||||
"scoring": "Full credit for either the parent branch plus the generated-state child, or the generated-state child alone with a clear explanation."
|
||||
},
|
||||
"control-root-children": {
|
||||
"expected": [
|
||||
"Gameplay Session Control Flow",
|
||||
"Player Input and Weapons Control Flow",
|
||||
"Simulation Tick Control Flow",
|
||||
"Entity Equipment and Persistence Flow",
|
||||
"Zone View Control Flow",
|
||||
"Agent Combat Control Flow",
|
||||
"Client Networking Control Flow",
|
||||
"Economy Server Control Flow",
|
||||
"CultCache Bootstrap and Data Load Flow",
|
||||
"Galaxy and Zone Generation Control Flow",
|
||||
"Narrative Placement Control Flow"
|
||||
],
|
||||
"scoring": "Full credit for eight or more correct major control-flow notes with no major hallucinations. Half credit for five to seven."
|
||||
},
|
||||
"control-central-hub": {
|
||||
"expected": {
|
||||
"node": "Galaxy and Zone Generation Control Flow",
|
||||
"degreeRank": "Highest non-root total degree in the current control-flow baseline JSON.",
|
||||
"baselineDegree": {
|
||||
"in": 6,
|
||||
"out": 8,
|
||||
"total": 14
|
||||
}
|
||||
},
|
||||
"scoring": "Full credit for Galaxy and Zone Generation Control Flow with JSON degree support. Half credit for Simulation Tick Control Flow if the answer correctly identifies it as another major hub but not the top one in the current baseline."
|
||||
},
|
||||
"control-network-bridge": {
|
||||
"expected": [
|
||||
"Client Networking Control Flow",
|
||||
"Economy Server Control Flow"
|
||||
],
|
||||
"scoring": "Full credit for both notes. Half credit for naming only one side and describing the client/server network edge correctly."
|
||||
},
|
||||
"control-player-to-simulation-chain": {
|
||||
"expected": [
|
||||
"Gameplay Session Control Flow",
|
||||
"Player Input and Weapons Control Flow",
|
||||
"Simulation Tick Control Flow",
|
||||
"Entity Equipment and Persistence Flow",
|
||||
"Zone View Control Flow"
|
||||
],
|
||||
"scoring": "Full credit for the core chain from gameplay session through input into simulation and equipment or rendering consequences. Half credit for a plausible partial chain with at least three correct nodes in order."
|
||||
},
|
||||
"cross-economy-server-anchor": {
|
||||
"expected": {
|
||||
"controlFlow": "Economy Server Control Flow",
|
||||
"sourceTreeAnchor": "Economy Server"
|
||||
},
|
||||
"scoring": "Full credit for mapping Economy Server Control Flow to Economy Server. Half credit if the answer names Economy.Server/ files but misses the source-tree note."
|
||||
},
|
||||
"cross-gameplay-session-anchor": {
|
||||
"expected": {
|
||||
"controlFlow": "Gameplay Session Control Flow",
|
||||
"sourceTreeAnchor": "Unity Client/Scripts/Gameplay Runtime"
|
||||
},
|
||||
"scoring": "Full credit for mapping Gameplay Session Control Flow to Gameplay Runtime. Half credit for naming ActionGameManager.cs or Unity Client/Scripts without the source-tree note."
|
||||
},
|
||||
"diagnostic-legibility-failures": {
|
||||
"expected": "Diagnostic only.",
|
||||
"scoring": "No score."
|
||||
},
|
||||
"diagnostic-image-vs-json": {
|
||||
"expected": "Diagnostic only.",
|
||||
"scoring": "No score."
|
||||
}
|
||||
}
|
||||
}
|
||||
136
benchmarks/aetheria-economy/questions.json
Normal file
136
benchmarks/aetheria-economy/questions.json
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"benchmark": "aetheria-source-tree-map",
|
||||
"instructions": "Answer from the allowed inputs only. For image probes, inspect the PNG preview before opening JSON. Use SVG only as vector reference if needed, not as the primary image input. Do not read gold.json until after answers are written.",
|
||||
"probes": [
|
||||
{
|
||||
"id": "source-top-split",
|
||||
"graph": "source-tree",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "From the source-tree rendering, name the four major branches directly under Source Tree Map.",
|
||||
"answerFormat": "List four note titles."
|
||||
},
|
||||
{
|
||||
"id": "source-authoring-cluster",
|
||||
"graph": "source-tree",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which source-tree branch owns GameData, Wwise, raw art sources, and stray authoring artifacts?",
|
||||
"answerFormat": "Name the branch and one or two visible child notes that support the answer."
|
||||
},
|
||||
{
|
||||
"id": "source-rendering-path",
|
||||
"graph": "source-tree",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which visible source-tree path leads to shader and compute work?",
|
||||
"answerFormat": "Give the path using note titles."
|
||||
},
|
||||
{
|
||||
"id": "source-shared-simulation-hub",
|
||||
"graph": "source-tree",
|
||||
"allowedInputs": [
|
||||
"image+json"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which non-root source-tree node appears to be a major shared-code hub, and does the JSON degree data support that impression?",
|
||||
"answerFormat": "Name the node, describe the visual cue, then cite its degree or relative degree rank from JSON."
|
||||
},
|
||||
{
|
||||
"id": "source-generated-config-branch",
|
||||
"graph": "source-tree",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Where does generated or local editor/build state belong in the source-tree map?",
|
||||
"answerFormat": "Name the branch or child note."
|
||||
},
|
||||
{
|
||||
"id": "control-root-children",
|
||||
"graph": "control-flow",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "From the control-flow rendering, list the major execution-order notes attached to Control Flow Map.",
|
||||
"answerFormat": "List as many direct major control-flow notes as the image makes readable."
|
||||
},
|
||||
{
|
||||
"id": "control-central-hub",
|
||||
"graph": "control-flow",
|
||||
"allowedInputs": [
|
||||
"image+json"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Ignoring the root Control Flow Map node, which control-flow node is the most central hub, and how does JSON support that?",
|
||||
"answerFormat": "Name the node and cite its degree or relative rank."
|
||||
},
|
||||
{
|
||||
"id": "control-network-bridge",
|
||||
"graph": "control-flow",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which two control-flow notes describe the client/server network edge?",
|
||||
"answerFormat": "Name both notes."
|
||||
},
|
||||
{
|
||||
"id": "control-player-to-simulation-chain",
|
||||
"graph": "control-flow",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "What visible control-flow chain carries player action toward simulation consequences?",
|
||||
"answerFormat": "List the main note titles in order."
|
||||
},
|
||||
{
|
||||
"id": "cross-economy-server-anchor",
|
||||
"graph": "cross-view",
|
||||
"allowedInputs": [
|
||||
"image+json"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which source-tree note should anchor Economy Server Control Flow in a future linked-view renderer?",
|
||||
"answerFormat": "Name the control-flow note and its source-tree anchor."
|
||||
},
|
||||
{
|
||||
"id": "cross-gameplay-session-anchor",
|
||||
"graph": "cross-view",
|
||||
"allowedInputs": [
|
||||
"image+json"
|
||||
],
|
||||
"maxScore": 1,
|
||||
"question": "Which source-tree note should anchor Gameplay Session Control Flow in a future linked-view renderer?",
|
||||
"answerFormat": "Name the control-flow note and its source-tree anchor."
|
||||
},
|
||||
{
|
||||
"id": "diagnostic-legibility-failures",
|
||||
"graph": "all",
|
||||
"allowedInputs": [
|
||||
"image"
|
||||
],
|
||||
"maxScore": 0,
|
||||
"question": "Which labels, edge paths, clusters, or crossings were hard to read from the image alone?",
|
||||
"answerFormat": "Freeform diagnostic notes. No score."
|
||||
},
|
||||
{
|
||||
"id": "diagnostic-image-vs-json",
|
||||
"graph": "all",
|
||||
"allowedInputs": [
|
||||
"image+json"
|
||||
],
|
||||
"maxScore": 0,
|
||||
"question": "What did the image make obvious, and what required JSON verification?",
|
||||
"answerFormat": "Two short lists: image-obvious and JSON-required."
|
||||
}
|
||||
]
|
||||
}
|
||||
54
benchmarks/aetheria-economy/source-tree-map.benchmark.json
Normal file
54
benchmarks/aetheria-economy/source-tree-map.benchmark.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "aetheria-source-tree-map",
|
||||
"description": "Aetheria-Economy source-tree and control-flow note graph benchmark.",
|
||||
"vaultRoot": "E:\\Projects\\Aetheria-Economy\\Aetheria\\Source Tree Map",
|
||||
"entryNote": "Source Tree Map",
|
||||
"includeUnreachable": false,
|
||||
"questionsPath": "questions.json",
|
||||
"goldPath": "gold.json",
|
||||
"candidates": [
|
||||
{
|
||||
"id": "sugiyama-msagl",
|
||||
"layout": "sugiyama",
|
||||
"renderer": "msagl",
|
||||
"description": "Baseline layered MSAGL layout with stock renderer."
|
||||
},
|
||||
{
|
||||
"id": "sugiyama-rich",
|
||||
"layout": "sugiyama",
|
||||
"renderer": "rich",
|
||||
"description": "Layered layout with custom renderer over MSAGL geometry."
|
||||
},
|
||||
{
|
||||
"id": "mds-msagl",
|
||||
"layout": "mds",
|
||||
"renderer": "msagl",
|
||||
"description": "Baseline MDS layout with stock renderer."
|
||||
},
|
||||
{
|
||||
"id": "mds-rich",
|
||||
"layout": "mds",
|
||||
"renderer": "rich",
|
||||
"description": "MDS layout with custom renderer over MSAGL geometry."
|
||||
}
|
||||
],
|
||||
"futureSweepAxes": {
|
||||
"layout": [
|
||||
"nodeSeparation",
|
||||
"layerSeparation",
|
||||
"edgeRoutingMode",
|
||||
"aspectRatio",
|
||||
"pinnedAnchors"
|
||||
],
|
||||
"renderer": [
|
||||
"backgroundColor",
|
||||
"fontSize",
|
||||
"labelWrapWidth",
|
||||
"nodePadding",
|
||||
"edgeOpacity",
|
||||
"semanticPalette",
|
||||
"clusterBoundaries"
|
||||
],
|
||||
"note": "These are not all wired into the current CLI yet. Keep them in the spec so the harness has a place for them when the renderer grows up."
|
||||
}
|
||||
}
|
||||
69
scripts/Measure-BenchmarkRun.ps1
Normal file
69
scripts/Measure-BenchmarkRun.ps1
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$resolvedRunDir = (Resolve-Path $RunDir).Path
|
||||
$manifestPath = Join-Path $resolvedRunDir "run-manifest.json"
|
||||
if (-not (Test-Path $manifestPath)) {
|
||||
throw "No run-manifest.json found in $resolvedRunDir"
|
||||
}
|
||||
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
$rows = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($candidate in $manifest.candidates) {
|
||||
$candidateDir = $candidate.directory
|
||||
$scoresPath = Join-Path $candidateDir "scores.json"
|
||||
if (-not (Test-Path $scoresPath)) {
|
||||
$rows.Add([pscustomobject]@{
|
||||
Candidate = $candidate.id
|
||||
Status = "missing scores.json"
|
||||
ProbeScore = $null
|
||||
ProbeMax = $null
|
||||
ProbePercent = $null
|
||||
ReadabilityAverage = $null
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$scores = Get-Content -LiteralPath $scoresPath -Raw | ConvertFrom-Json
|
||||
$scoredProbes = @($scores.probeScores | Where-Object { $_.maxScore -gt 0 -and $null -ne $_.score })
|
||||
$probeScore = 0.0
|
||||
$probeMax = 0.0
|
||||
foreach ($probe in $scoredProbes) {
|
||||
$probeScore += [double]$probe.score
|
||||
$probeMax += [double]$probe.maxScore
|
||||
}
|
||||
|
||||
$readabilityScores = @(
|
||||
$scores.readability.labelLegibility,
|
||||
$scores.readability.edgeTraceability,
|
||||
$scores.readability.clusterReadability,
|
||||
$scores.readability.flowReadability,
|
||||
$scores.readability.clutterControl
|
||||
) | Where-Object { $null -ne $_ }
|
||||
|
||||
$readabilityAverage = $null
|
||||
if ($readabilityScores.Count -gt 0) {
|
||||
$readabilityAverage = [math]::Round((($readabilityScores | Measure-Object -Average).Average), 2)
|
||||
}
|
||||
|
||||
$probePercent = $null
|
||||
if ($probeMax -gt 0) {
|
||||
$probePercent = [math]::Round(($probeScore / $probeMax) * 100, 1)
|
||||
}
|
||||
|
||||
$rows.Add([pscustomobject]@{
|
||||
Candidate = $candidate.id
|
||||
Status = "scored"
|
||||
ProbeScore = [math]::Round($probeScore, 2)
|
||||
ProbeMax = [math]::Round($probeMax, 2)
|
||||
ProbePercent = $probePercent
|
||||
ReadabilityAverage = $readabilityAverage
|
||||
})
|
||||
}
|
||||
|
||||
$rows | Sort-Object ProbePercent -Descending | Format-Table -AutoSize
|
||||
330
scripts/New-BenchmarkRun.ps1
Normal file
330
scripts/New-BenchmarkRun.ps1
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
param(
|
||||
[string]$SpecPath,
|
||||
[string]$OutputRoot,
|
||||
[string]$ProjectPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = (Resolve-Path (Join-Path $scriptRoot "..")).Path
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SpecPath)) {
|
||||
$SpecPath = Join-Path $repoRoot "benchmarks\aetheria-economy\source-tree-map.benchmark.json"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
|
||||
$OutputRoot = Join-Path $repoRoot "out\benchmarks"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ProjectPath)) {
|
||||
$ProjectPath = Join-Path $repoRoot "EpiphanyGraph.csproj"
|
||||
}
|
||||
|
||||
function Get-SvgRasterizerPath {
|
||||
$candidates = @(
|
||||
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-SvgCanvasSize {
|
||||
param(
|
||||
[string]$SvgPath
|
||||
)
|
||||
|
||||
$header = [string]::Join("`n", (Get-Content -LiteralPath $SvgPath -TotalCount 4))
|
||||
$width = 2048
|
||||
$height = 2048
|
||||
|
||||
$widthMatch = [regex]::Match($header, 'width="([0-9]+(?:\.[0-9]+)?)"')
|
||||
if ($widthMatch.Success) {
|
||||
$width = [math]::Ceiling([double]$widthMatch.Groups[1].Value)
|
||||
}
|
||||
|
||||
$heightMatch = [regex]::Match($header, 'height="([0-9]+(?:\.[0-9]+)?)"')
|
||||
if ($heightMatch.Success) {
|
||||
$height = [math]::Ceiling([double]$heightMatch.Groups[1].Value)
|
||||
}
|
||||
|
||||
$padding = 32
|
||||
return [pscustomobject]@{
|
||||
Width = [math]::Min([math]::Max($width + $padding, 512), 8192)
|
||||
Height = [math]::Min([math]::Max($height + $padding, 512), 8192)
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-SvgToPng {
|
||||
param(
|
||||
[string]$BrowserPath,
|
||||
[string]$SvgPath,
|
||||
[string]$PngPath
|
||||
)
|
||||
|
||||
$canvas = Get-SvgCanvasSize -SvgPath $SvgPath
|
||||
$svgUri = [System.Uri]::new((Resolve-Path $SvgPath).Path).AbsoluteUri
|
||||
|
||||
Remove-Item -LiteralPath $PngPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$browserArgs = @(
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
"--window-size=$($canvas.Width),$($canvas.Height)",
|
||||
"--screenshot=$PngPath",
|
||||
$svgUri
|
||||
)
|
||||
|
||||
$stdoutPath = [System.IO.Path]::GetTempFileName()
|
||||
$stderrPath = [System.IO.Path]::GetTempFileName()
|
||||
|
||||
try {
|
||||
$process = Start-Process `
|
||||
-FilePath $BrowserPath `
|
||||
-ArgumentList $browserArgs `
|
||||
-Wait `
|
||||
-NoNewWindow `
|
||||
-PassThru `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath
|
||||
|
||||
for ($attempt = 0; $attempt -lt 20; $attempt++) {
|
||||
if (Test-Path $PngPath) {
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $stdoutPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PngPath)) {
|
||||
throw "Failed to rasterize '$SvgPath' to '$PngPath'."
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-BenchmarkPath {
|
||||
param(
|
||||
[string]$BaseDirectory,
|
||||
[string]$PathValue
|
||||
)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue)) {
|
||||
return (Resolve-Path $PathValue).Path
|
||||
}
|
||||
|
||||
$fromSpec = Join-Path $BaseDirectory $PathValue
|
||||
if (Test-Path $fromSpec) {
|
||||
return (Resolve-Path $fromSpec).Path
|
||||
}
|
||||
|
||||
return (Resolve-Path (Join-Path $repoRoot $PathValue)).Path
|
||||
}
|
||||
|
||||
$resolvedSpecPath = (Resolve-Path $SpecPath).Path
|
||||
$specDirectory = Split-Path -Parent $resolvedSpecPath
|
||||
$spec = Get-Content -LiteralPath $resolvedSpecPath -Raw | ConvertFrom-Json
|
||||
$questionsPath = Resolve-BenchmarkPath -BaseDirectory $specDirectory -PathValue $spec.questionsPath
|
||||
$goldPath = Resolve-BenchmarkPath -BaseDirectory $specDirectory -PathValue $spec.goldPath
|
||||
$questions = Get-Content -LiteralPath $questionsPath -Raw | ConvertFrom-Json
|
||||
$svgRasterizerPath = Get-SvgRasterizerPath
|
||||
|
||||
$runId = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
|
||||
$runRoot = Join-Path $OutputRoot $spec.name
|
||||
$runDir = Join-Path $runRoot $runId
|
||||
$candidateRoot = Join-Path $runDir "candidates"
|
||||
|
||||
New-Item -ItemType Directory -Path $candidateRoot -Force | Out-Null
|
||||
Copy-Item -LiteralPath $resolvedSpecPath -Destination (Join-Path $runDir "benchmark.json")
|
||||
Copy-Item -LiteralPath $questionsPath -Destination (Join-Path $runDir "questions.json")
|
||||
Copy-Item -LiteralPath $goldPath -Destination (Join-Path $runDir "gold.json")
|
||||
|
||||
$candidateResults = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($candidate in $spec.candidates) {
|
||||
$candidateId = $candidate.id
|
||||
$candidateDir = Join-Path $candidateRoot $candidateId
|
||||
$renderDir = Join-Path $candidateDir "render"
|
||||
$logPath = Join-Path $candidateDir "generate.log"
|
||||
New-Item -ItemType Directory -Path $renderDir -Force | Out-Null
|
||||
|
||||
$dotnetArgs = @(
|
||||
"run",
|
||||
"--project",
|
||||
$ProjectPath,
|
||||
"--",
|
||||
"--vault-root",
|
||||
$spec.vaultRoot,
|
||||
"--entry-note",
|
||||
$spec.entryNote,
|
||||
"--output-dir",
|
||||
$renderDir,
|
||||
"--layout",
|
||||
$candidate.layout
|
||||
)
|
||||
|
||||
if ($candidate.PSObject.Properties.Name -contains "renderer" -and -not [string]::IsNullOrWhiteSpace($candidate.renderer)) {
|
||||
$dotnetArgs += @(
|
||||
"--renderer",
|
||||
$candidate.renderer
|
||||
)
|
||||
}
|
||||
|
||||
if ($spec.includeUnreachable -eq $true) {
|
||||
$dotnetArgs += "--include-unreachable"
|
||||
}
|
||||
|
||||
Write-Host "Rendering candidate '$candidateId'..."
|
||||
& dotnet @dotnetArgs 2>&1 | Tee-Object -FilePath $logPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Candidate '$candidateId' failed. See $logPath"
|
||||
}
|
||||
|
||||
$pngPreviewPaths = New-Object System.Collections.Generic.List[object]
|
||||
if ($svgRasterizerPath) {
|
||||
foreach ($graphKey in @("source-tree", "control-flow")) {
|
||||
$svgPath = Join-Path $renderDir "$graphKey.svg"
|
||||
if (-not (Test-Path $svgPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$pngPath = Join-Path $renderDir "$graphKey.png"
|
||||
Convert-SvgToPng -BrowserPath $svgRasterizerPath -SvgPath $svgPath -PngPath $pngPath
|
||||
$pngPreviewPaths.Add([ordered]@{
|
||||
graph = $graphKey
|
||||
pngPath = $pngPath
|
||||
svgPath = $svgPath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $questionsPath -Destination (Join-Path $candidateDir "questions.json")
|
||||
|
||||
$answerSheetPath = Join-Path $candidateDir "answer-sheet.md"
|
||||
$scoreTemplatePath = Join-Path $candidateDir "scores.template.json"
|
||||
|
||||
$renderRelative = "render"
|
||||
$answerLines = New-Object System.Collections.Generic.List[string]
|
||||
$answerLines.Add("# Benchmark Answer Sheet")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Candidate: ``$candidateId``")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Render directory: ``$renderRelative``")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Important: answer these probes before opening the run-level ``gold.json``. The whole point is not to grade a contaminated oracle. Tiny basement science, yes, but still science.")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Open as needed:")
|
||||
$answerLines.Add("")
|
||||
if ($svgRasterizerPath) {
|
||||
$answerLines.Add("- ``render\source-tree.png`` for source-tree image probes")
|
||||
$answerLines.Add("- ``render\control-flow.png`` for control-flow image probes")
|
||||
$answerLines.Add("- ``render\source-tree.svg`` and ``render\control-flow.svg`` only as vector reference for the candidate under test, not as the primary image input")
|
||||
}
|
||||
else {
|
||||
$answerLines.Add("- ``render\source-tree.svg``")
|
||||
$answerLines.Add("- ``render\control-flow.svg``")
|
||||
$answerLines.Add("- Warning: no local browser rasterizer was found, so PNG previews were not generated.")
|
||||
}
|
||||
$answerLines.Add("- Ignore ``*.msagl.svg`` and ``*.rich.svg`` during blind review unless you are doing post-hoc renderer autopsy work")
|
||||
$answerLines.Add("- ``render\source-tree.json``")
|
||||
$answerLines.Add("- ``render\control-flow.json``")
|
||||
$answerLines.Add("- ``render\cross-links.json``")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("## Probe Answers")
|
||||
|
||||
foreach ($probe in $questions.probes) {
|
||||
$allowedInputs = [string]::Join(", ", @($probe.allowedInputs))
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("### $($probe.id)")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("- Graph: ``$($probe.graph)``")
|
||||
$answerLines.Add("- Allowed inputs: ``$allowedInputs``")
|
||||
$answerLines.Add("- Max score: ``$($probe.maxScore)``")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Question: $($probe.question)")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Expected answer format: $($probe.answerFormat)")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("Answer:")
|
||||
$answerLines.Add("")
|
||||
$answerLines.Add("> ")
|
||||
}
|
||||
|
||||
$answerLines | Set-Content -LiteralPath $answerSheetPath
|
||||
|
||||
$probeScores = @(
|
||||
foreach ($probe in $questions.probes) {
|
||||
[ordered]@{
|
||||
probeId = $probe.id
|
||||
maxScore = $probe.maxScore
|
||||
score = $null
|
||||
notes = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$scoreTemplate = [ordered]@{
|
||||
candidateId = $candidateId
|
||||
scoredBy = ""
|
||||
scoredAtUtc = ""
|
||||
readability = [ordered]@{
|
||||
labelLegibility = $null
|
||||
edgeTraceability = $null
|
||||
clusterReadability = $null
|
||||
flowReadability = $null
|
||||
clutterControl = $null
|
||||
notes = ""
|
||||
}
|
||||
probeScores = $probeScores
|
||||
}
|
||||
|
||||
$scoreTemplate | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $scoreTemplatePath
|
||||
|
||||
$candidateResults.Add([ordered]@{
|
||||
id = $candidateId
|
||||
description = $candidate.description
|
||||
layout = $candidate.layout
|
||||
renderer = if ($candidate.PSObject.Properties.Name -contains "renderer" -and -not [string]::IsNullOrWhiteSpace($candidate.renderer)) { $candidate.renderer } else { "msagl" }
|
||||
directory = $candidateDir
|
||||
renderDirectory = $renderDir
|
||||
rasterizer = $svgRasterizerPath
|
||||
imagePreviews = @($pngPreviewPaths.ToArray())
|
||||
answerSheet = $answerSheetPath
|
||||
scoreTemplate = $scoreTemplatePath
|
||||
log = $logPath
|
||||
})
|
||||
}
|
||||
|
||||
$candidateArray = @($candidateResults.ToArray())
|
||||
$runManifest = [ordered]@{
|
||||
benchmark = $spec.name
|
||||
runId = $runId
|
||||
createdAtUtc = (Get-Date).ToUniversalTime().ToString("O")
|
||||
specPath = $resolvedSpecPath
|
||||
questionsPath = (Join-Path $runDir "questions.json")
|
||||
goldPath = (Join-Path $runDir "gold.json")
|
||||
outputDirectory = $runDir
|
||||
candidates = $candidateArray
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $runDir "run-manifest.json"
|
||||
$runManifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $manifestPath
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Benchmark run created:"
|
||||
Write-Host $runDir
|
||||
Write-Host ""
|
||||
Write-Host "Review each candidate answer-sheet.md before opening gold.json. Don't cheat unless you enjoy measuring pasteurized vibes."
|
||||
Loading…
Reference in a new issue