Files
markitect-main/markitect/infospace/relation_parser.py
tegwick 2d45425b25 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>
2026-02-23 06:04:28 +01:00

138 lines
3.4 KiB
Python

"""
Relation triplet parser.
Reads structured :class:`RelationMeta` objects from relation markdown
files in ``output/relations/``.
File format::
# Subject — predicate — Object
## Subject
Subject Entity Title
## Predicate
predicate phrase
## Object
Object Entity Title
## Relation Type
constrains
## VSM Channel
S1 → S2
## Evidence
Book I, Chapter 3: "..."
## Feedback Role
Part of the Market Expansion loop: ...
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import List, Optional, Sequence
from markitect.core.parser import parse_markdown_to_ast
from markitect.core.section_tree import (
build_section_tree,
extract_section_text,
slugify,
)
from .relation_models import RelationMeta
logger = logging.getLogger(__name__)
def _find_h2(tree_root: dict, slug: str) -> Optional[dict]:
for child in tree_root.get("children", []):
if child["level"] == 2 and child["slug"] == slug:
return child
return None
def _section_text(root: dict, slug: str) -> str:
node = _find_h2(root, slug)
return extract_section_text(node).strip() if node else ""
def _slug_from_title(title: str) -> str:
"""Convert entity title to slug (same as slugify used in entity_parser)."""
return slugify(title)
def parse_relation_file(path: Path) -> RelationMeta:
"""Parse a single relation markdown file into :class:`RelationMeta`.
Raises:
ValueError: If required sections are missing.
"""
content = path.read_text(encoding="utf-8")
tokens = parse_markdown_to_ast(content)
tree = build_section_tree(tokens)
# Find H1
h1 = next(
(c for c in tree["children"] if c["level"] == 1),
None,
)
if h1 is None:
raise ValueError(f"No H1 heading in {path}")
root = h1
subject = _section_text(root, "subject")
predicate = _section_text(root, "predicate")
obj = _section_text(root, "object")
relation_type = _section_text(root, "relation_type")
vsm_channel = _section_text(root, "vsm_channel")
evidence = _section_text(root, "evidence")
feedback_role = _section_text(root, "feedback_role")
if not subject:
raise ValueError(f"Missing ## Subject in {path}")
if not predicate:
raise ValueError(f"Missing ## Predicate in {path}")
if not obj:
raise ValueError(f"Missing ## Object in {path}")
subject_slug = _slug_from_title(subject)
object_slug = _slug_from_title(obj)
# Derive canonical slug from file stem
slug = path.stem
return RelationMeta(
slug=slug,
subject=subject,
subject_slug=subject_slug,
predicate=predicate,
object=obj,
object_slug=object_slug,
relation_type=relation_type,
vsm_channel=vsm_channel,
evidence=evidence,
feedback_role=feedback_role,
source_path=str(path),
)
def parse_relations_directory(
directory: Path,
) -> List[RelationMeta]:
"""Parse all relation files in *directory*.
Malformed files are skipped with a warning.
"""
relations: List[RelationMeta] = []
for md_file in sorted(directory.glob("*.md")):
try:
relations.append(parse_relation_file(md_file))
except Exception as exc:
logger.warning("Skipping relation file %s: %s", md_file.name, exc)
return relations