Skip to content

feat(language)!: sets and properties replace relations, and a relations section is refused - #5

Draft
FBumann wants to merge 2 commits into
claude/sets-properties-prototype-9m3zu6-4-typesettingfrom
claude/sets-properties-prototype-9m3zu6-5-migration
Draft

FBumann wants to merge 2 commits into
claude/sets-properties-prototype-9m3zu6-4-typesettingfrom
claude/sets-properties-prototype-9m3zu6-5-migration

Conversation

@FBumann

@FBumann FBumann commented Oct 23, 2026

Copy link
Copy Markdown

Prompt: 5. Migration: a rewrite script turns every relation in examples/ into sets and properties, the generated pages and golden files are regenerated, and relations: is removed. No alias, because the project is pre-1.0.

Note

The following content was generated by AI.

What this changes

relations: is gone. Every relation in examples/, the YAML fixtures, the golden model and the docs is rewritten as sets and properties, and every generated page is regenerated. A file that still declares relations: is refused by the closed schema. Stacked on #4.

Why

This is PR 5 of the stack in which sets and properties replace relations. It breaks every file that declares relations:, so the release that carries it raises the minor version.

How the rewrite maps a relation
relation becomes
a bare relation, connection: {key: [generator, bus]} a set of the same name, connection: {dims: [generator, bus]}, with roles kept as a mapping
keyed by one dimension, Line_bus0: {key: line, values: bus} a property of that dimension, line: {bus0: bus}
keyed by several, zone_of: {key: [generator, period], values: zone} a set generator_period with the same missing:, and a property zone
  • Property names: the script takes the relation's last word where it extends the column name (Line_bus0 → bus0); otherwise the column name; otherwise the relation's name without _of (area_of → area); and relation_column last.
  • Reads: R[c] becomes subject[c']. A bare keyed R beside a comparison becomes subject[p], and elsewhere a presence test, or the set's name. A read inside a YAML flow mapping is quoted.
  • The math is checked: for every file, the script loads the old and the new file, and every constraint, expression and objective must keep the same dims and the same tree of node kinds. All 31 example files and the fixtures pass that check.
  • Generated, not migrated: examples/pypsa/ is what tools/pypsa_split.py writes from examples/pypsa.yaml, and the split output was byte-identical to the per-file migration.
  • Hand-placed: the golden model's blocks, and the two operators.md properties from_bus/to_bus, which the naming rule would have called bus and line_to.
The rewrite script, which is not in the tree

The project keeps no migration aid for a retired spelling, so the script is here rather than in tools/. Run it against a tree that still reads relations: (it loads both forms to compare the math). The naming rule in its docstring is older than the _names function, which is the rule described above.

"""Rewrite every `relations:` entry of a spec file as `sets:` and `properties:`, in place, keeping comments."""

from __future__ import annotations

import re
import sys
import textwrap
from dataclasses import dataclass, field
from pathlib import Path

from mathspec import to_spec
from mathspec._yaml import parse_yaml, read_yaml
from mathspec.program import walk
from mathspec.spec import side_columns

COMPARATOR = r'(?:==|!=|<=|>=|<|>)'


@dataclass
class Plan:
    sets: dict[str, dict] = field(default_factory=dict)
    properties: dict[str, dict[str, dict]] = field(default_factory=dict)
    subject: dict[str, str] = field(default_factory=dict)  # relation -> subject it reads
    renamed: dict[str, dict[str, str]] = field(default_factory=dict)  # relation -> value role -> property
    comments: dict[str, str] = field(default_factory=dict)


def plan(spec: dict, comments: dict[str, str]) -> Plan:
    out = Plan()
    declared = {*spec.get('dimensions', {}), *spec.get('parameters', {}), *spec.get('variables', {})}
    declared |= {*spec.get('expressions', {}), *spec.get('conditions', {}), *spec.get('macros', {})}
    declared |= set(spec.get('relations', {}))
    dimensions = set(spec.get('dimensions', {}))
    for name, rel in spec.get('relations', {}).items():
        key, values = side_columns(rel['key']), side_columns(rel.get('values'))
        missing = rel.get('missing', 'refused')
        out.comments[name] = comments.get(name, '')
        if not values:
            dims = [d for _, d in key]
            out.sets[name] = {'dims': dims if all(r == d for r, d in key) else dict(key)}
            if missing != 'refused':
                out.sets[name]['missing'] = missing
            if rel.get('description'):
                out.sets[name]['description'] = rel['description']
            continue
        assert all(r == d for r, d in key), f'{name}: a key column named apart from its dimension'
        if len(key) == 1:
            subject, prop_missing = key[0][1], missing
        else:
            dims = [d for _, d in key]
            subject = '_'.join(dims)
            shape = {'dims': dims, **({'missing': missing} if missing != 'refused' else {})}
            if subject in declared or (subject in out.sets and out.sets[subject] != shape):
                subject = f'{name}_rows'
            out.sets.setdefault(subject, shape)
            prop_missing = 'refused'
        out.subject[name] = subject
        group = out.properties.setdefault(subject, {})
        members = {r for r, _ in key}
        out.renamed[name] = {}
        for role, dim in values:
            prop = next(
                c for c in _names(name, role, len(values))
                if c not in group and c not in members and (c not in dimensions or c == dim)
            )
            entry = {'into': dim, **({'missing': prop_missing} if prop_missing != 'refused' else {})}
            if rel.get('description'):
                entry['description'] = rel['description']
            group[prop] = entry
            out.renamed[name][role] = prop
    return out


def _names(relation: str, role: str, values: int) -> list[str]:
    """The names a property may take, best first."""
    last = relation.rsplit('_', 1)[-1]
    names = [last] if values == 1 and last.lower().startswith(role.lower()) and last != role else []
    names.append(role)
    if values == 1:
        names += [relation.removesuffix('_of')] if relation.endswith('_of') else []
        names.append(relation)
    names.append(f'{relation}_{role}')
    return names

# ... block(), _entry(), _relations_block(), _split_comment(), rewrite_code(), rewrite(),
# _quoted_in_flow(), shape_of() and migrate() as run: they write the blocks in
# place of `relations:`, rewrite each read line by line outside comments and
# descriptions, and assert shape_of(old) == shape_of(new) before writing.

The full script, 316 lines, is in the session scratchpad. I can commit it under tools/ on request.

What else changed, and where coverage moved
  • src/: RelationSpec, Spec.relations and the relation-entry rules are deleted. Namespace.relations becomes Namespace.sets, and the name kind relation becomes set. Every message that named a relation now names a set, a property or a subject. Two typesetter branches that only a keyed relation without a subject reached are deleted, because the golden coverage test showed no model can reach them.
  • Three fixes the old tests did not exercise:
    • The case-exclusivity witness printed g.a[a], the table name; it now prints g[a].
    • The inequality hint for g[h] > 'b' offered position(g), advice about the subject dimension rather than its property.
    • A group was named by its table name in the "within each … group" refusal.
  • Docs: relations.md is deleted. Its rules for how a call joins and groups move into sets.md (sums, lookups, partitions, tests). about/relations-as-linear-maps.md is now about/sets-and-properties-as-linear-maps.md. Every link, table row and YAML example names sets and properties, and every example on the pages I touched loads.
  • Tests: relation-only tests are deleted: the relation-entry rules, a relation's missing:, and a bare comparison of a two-value relation. The set and property versions of those rules are in tests/test_sets.py. Every other test reads sets and properties and asserts the new messages.
Gates, and the mutation table
guard deleted suite
a set compared with no column is refused caught
a member compared is refused caught
an ordered dimension's hint only where no column is read caught
a case witness spells subject[property] caught (2)
a group is spelled subject[property] caught
a dimension with no properties is named caught

Generated by Claude Code

…ns section is refused

Every relation in examples/, the fixtures and the docs is rewritten as sets
and properties, and the generated pages and golden files are regenerated.
`relations:` leaves the schema, so a file that still declares it is refused
with the closed schema's own message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6yvGzZN2iR4WyA9RumvvJ
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6yvGzZN2iR4WyA9RumvvJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants