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:
137
markitect/infospace/relation_parser.py
Normal file
137
markitect/infospace/relation_parser.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user