feat(infospace): add L3 relation graph with VSM-aware triplets (S2.8)
Implements the L3 relation graph layer — a directed graph of (Subject,
Predicate, Object) triplets annotated with VSM channel codes and feedback
roles. Triplets are authored as markdown files under output/relations/,
parsed into RelationMeta dataclasses, and analysed with networkx.
New modules:
- markitect/infospace/relation_models.py — RelationMeta dataclass +
RELATION_TYPES controlled vocabulary (15 relation classes → VSM codes)
- markitect/infospace/relation_parser.py — parse_relation_file() and
parse_relations_directory()
New schema: examples/infospace-with-history/schemas/relation-schema-v1.0.md
— file naming convention, required sections, controlled vocabulary table
15 seed relation files covering the three core WoN feedback loops:
- Capital Accumulation loop (positive reinforcement, S1/S3)
- Market Price Balancing loop (negative feedback, S2/S3)
- Market Extent mutual dependency (S1/S2)
Plus structural relations: wages regulation, rent residual, price
decomposition, invisible hand coordination
CLI: markitect infospace relations [--entity SLUG] [--vsm FILTER]
[--loops] [--stats]
- Builds directed graph from parsed files
- Detects feedback loops via nx.simple_cycles()
- 6 loops found from 15 seed relations (3 intended + 3 emergent)
- --stats aggregates by VSM system code (strips parentheticals)
Config: InfospaceConfig gains relations_dir (default output/relations)
infospace.yaml: schemas.relation references relation-schema-v1.0.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -299,6 +299,126 @@ def eval_summary(config_path: Optional[str], update_metrics: bool):
|
||||
click.echo(f"\nUpdated metrics.yaml: per_entity_mean = {mean_overall:.4f}")
|
||||
|
||||
|
||||
# ── relations ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@infospace_commands.command()
|
||||
@click.option("--config", "config_path", default=None, help="Path to infospace.yaml.")
|
||||
@click.option("--entity", "entity_slug", default=None,
|
||||
help="Show only relations involving this entity slug.")
|
||||
@click.option("--vsm", "vsm_filter", default=None,
|
||||
help="Show only relations whose VSM channel contains this string (e.g. S2, S3).")
|
||||
@click.option("--loops", "loops_only", is_flag=True, default=False,
|
||||
help="Show only feedback loops (cycles in the relation graph).")
|
||||
@click.option("--stats", "stats_only", is_flag=True, default=False,
|
||||
help="Show aggregate statistics only, no individual relations.")
|
||||
def relations(config_path: Optional[str], entity_slug: Optional[str],
|
||||
vsm_filter: Optional[str], loops_only: bool, stats_only: bool):
|
||||
"""Show the L3 relation graph — triplets, feedback loops, and VSM channels."""
|
||||
cfg, cfg_path = _load_config_or_exit(config_path)
|
||||
root = cfg_path.parent
|
||||
|
||||
from markitect.infospace.relation_parser import parse_relations_directory
|
||||
|
||||
relations_dir = root / cfg.relations_dir
|
||||
if not relations_dir.is_dir():
|
||||
click.echo("No relations directory found. Create output/relations/ and add relation files.")
|
||||
return
|
||||
|
||||
all_relations = parse_relations_directory(relations_dir)
|
||||
if not all_relations:
|
||||
click.echo("No relation files found in " + str(relations_dir))
|
||||
return
|
||||
|
||||
# Build directed graph for cycle detection
|
||||
try:
|
||||
import networkx as nx
|
||||
G = nx.DiGraph()
|
||||
for r in all_relations:
|
||||
G.add_edge(r.subject_slug, r.object_slug,
|
||||
predicate=r.predicate,
|
||||
relation_type=r.relation_type,
|
||||
vsm_channel=r.vsm_channel,
|
||||
slug=r.slug)
|
||||
except ImportError:
|
||||
G = None
|
||||
|
||||
# Find feedback loops
|
||||
loops = []
|
||||
if G is not None:
|
||||
try:
|
||||
loops = list(nx.simple_cycles(G))
|
||||
except Exception:
|
||||
loops = []
|
||||
|
||||
# Stats summary
|
||||
import re as _re
|
||||
|
||||
def _vsm_code(channel: str) -> str:
|
||||
"""Strip parenthetical description, returning just the system code (e.g. 'S3 → S1')."""
|
||||
return _re.sub(r'\s*\(.*', '', channel).strip() or channel
|
||||
|
||||
n = len(all_relations)
|
||||
vsm_counts: dict = {}
|
||||
type_counts: dict = {}
|
||||
for r in all_relations:
|
||||
vsm_counts[_vsm_code(r.vsm_channel)] = vsm_counts.get(_vsm_code(r.vsm_channel), 0) + 1
|
||||
type_counts[r.relation_type] = type_counts.get(r.relation_type, 0) + 1
|
||||
|
||||
click.echo(f"Relation graph — {n} relations")
|
||||
if G is not None:
|
||||
click.echo(f" Entities in graph: {G.number_of_nodes()}")
|
||||
click.echo(f" Feedback loops: {len(loops)}")
|
||||
click.echo()
|
||||
|
||||
if stats_only:
|
||||
click.echo("Relation types:")
|
||||
for rt, count in sorted(type_counts.items(), key=lambda x: -x[1]):
|
||||
click.echo(f" {rt:<25} {count:>4}")
|
||||
click.echo()
|
||||
click.echo("VSM channels:")
|
||||
for ch, count in sorted(vsm_counts.items(), key=lambda x: -x[1]):
|
||||
click.echo(f" {ch:<20} {count:>4}")
|
||||
return
|
||||
|
||||
# Feedback loops section
|
||||
if loops or loops_only:
|
||||
if loops:
|
||||
click.echo(f"Feedback loops ({len(loops)}):")
|
||||
for i, cycle in enumerate(loops, 1):
|
||||
click.echo(f" Loop {i}: {' → '.join(cycle)} → {cycle[0]}")
|
||||
click.echo()
|
||||
elif loops_only:
|
||||
click.echo("No feedback loops detected in current relation set.")
|
||||
return
|
||||
|
||||
if loops_only:
|
||||
return
|
||||
|
||||
# Filter relations
|
||||
filtered = all_relations
|
||||
if entity_slug:
|
||||
filtered = [r for r in filtered
|
||||
if entity_slug in (r.subject_slug, r.object_slug)]
|
||||
if not filtered:
|
||||
click.echo(f"No relations found involving '{entity_slug}'.")
|
||||
return
|
||||
if vsm_filter:
|
||||
filtered = [r for r in filtered if vsm_filter in r.vsm_channel]
|
||||
if not filtered:
|
||||
click.echo(f"No relations with VSM channel containing '{vsm_filter}'.")
|
||||
return
|
||||
|
||||
# Display relations
|
||||
click.echo(f"{'Subject':<35} {'Predicate':<30} {'Object':<35} {'VSM'}")
|
||||
click.echo("-" * 110)
|
||||
for r in filtered:
|
||||
subj = r.subject[:33] + ".." if len(r.subject) > 35 else r.subject
|
||||
obj = r.object[:33] + ".." if len(r.object) > 35 else r.object
|
||||
pred = r.predicate[:28] + ".." if len(r.predicate) > 30 else r.predicate
|
||||
click.echo(f"{subj:<35} {pred:<30} {obj:<35} {r.vsm_channel}")
|
||||
|
||||
|
||||
# ── viability ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user