Source code for knowledgespaces.io.legacy

"""Strict readers/writers for the binary KST and SRBT 2.0 text formats.

The format specification is derived from the kstIO 0.5-1 public readers and
writers. This is an independent implementation, not a translation of its
GPL source. Neither format stores item labels: preserve the ordered labels
separately and supply them on read. No missing-value code is defined here.
"""

from __future__ import annotations

from collections.abc import Sequence
from contextlib import suppress
from pathlib import Path
from typing import Literal

from knowledgespaces.structures.knowledge_structure import KnowledgeStructure
from knowledgespaces.structures.relations import SurmiseRelation

LegacyKind = Literal["structure", "space", "basis", "data", "relation", "family"]
LegacyFormat = Literal["KST", "SRBT", "matrix"]

_HEADERS = {
    "structure": "#SRBT v2.0 structure ASCII",
    "space": "#SRBT v2.0 space",
    "basis": "#SRBT v2.0 basis",
    "data": "#SRBT v2.0 data",
    "relation": "#SRBT v2.0 relation",
}


[docs] def read_legacy_matrix( path: str | Path, *, kind: LegacyKind, format: LegacyFormat | Literal["auto"] = "auto", items: Sequence[str] | None = None, ) -> tuple[list[str], list[list[int]]]: """Read ordered labels and binary rows, preserving row order/duplicates. ``kind`` declares the expected object and checks SRBT headers. This low level function validates syntax and dimensions, not knowledge-space axioms. With no ``items``, labels are the strings ``"1"`` through ``"n"``. ``auto`` recognizes SRBT by its header and otherwise validates both KST and plain matrix syntax. Ambiguous files require an explicit format. Only contiguous comment lines after SRBT dimensions are accepted. Wrong widths, nonbinary cells, missing/extra rows and unsupported headers fail. Structure reads also accept SRBT space headers, with or without ``ASCII``. """ _check_kind(kind) lines = Path(path).read_text(encoding="utf-8-sig").splitlines() lines = [line.strip() for line in lines] if not lines: raise ValueError("Empty legacy file.") if format == "auto": if lines[0].startswith("#SRBT"): matrix = _parse(lines, kind, "SRBT") else: possibilities = [] for candidate in ("KST", "matrix"): with suppress(ValueError): possibilities.append(_parse(lines, kind, candidate)) if len(possibilities) != 1: raise ValueError("Invalid or ambiguous legacy file; specify format explicitly.") matrix = possibilities[0] else: matrix = _parse(lines, kind, format) labels = [str(i + 1) for i in range(len(matrix[0]))] if items is None else list(items) _validate_labels(labels, len(matrix[0])) return labels, matrix
[docs] def write_legacy_matrix( items: Sequence[str], matrix: Sequence[Sequence[int]], path: str | Path, *, kind: LegacyKind, format: LegacyFormat = "KST", ) -> None: """Write a binary matrix as KST, SRBT or unheaded rows. ``items`` fixes the column order but labels are not stored in the file. SRBT supports structure, space, basis, data and relation. KST supports all these except relation, which has only SRBT/plain-matrix syntax. Matrices must have at least one row and column. No axioms are repaired. All validation occurs before opening the output file. """ _check_kind(kind) _check_format(kind, format) labels = list(items) _validate_labels(labels, len(labels)) rows = [list(row) for row in matrix] if not rows or any( len(row) != len(labels) or any(v not in (0, 1) for v in row) for row in rows ): raise ValueError("Expected nonempty rectangular binary matrix matching items.") if kind == "relation" and len(rows) != len(labels): raise ValueError("Relation matrix must be square.") lines = [] if format == "SRBT": lines.append(_HEADERS[kind]) if format != "matrix": lines.append(str(len(labels))) if kind != "relation": lines.append(str(len(rows))) lines.extend("".join(str(int(v)) for v in row) for row in rows) Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
[docs] def read_legacy_structure( path: str | Path, *, format: LegacyFormat | Literal["auto"] = "auto", items: Sequence[str] | None = None, ) -> KnowledgeStructure: """Read a structure, requiring both ∅ and Q in the input rows. Unlike the permissive structure constructor, this importer never inserts absent endpoint states. A space header is accepted as a structure without claiming that union closure has been verified; inspect ``is_knowledge_space``. """ labels, rows = read_legacy_matrix(path, kind="structure", format=format, items=items) states = {frozenset(q for q, bit in zip(labels, row, strict=True) if bit) for row in rows} if frozenset() not in states or frozenset(labels) not in states: raise ValueError("A structure file must contain both the empty and full state.") return KnowledgeStructure(labels, states)
[docs] def write_legacy_structure( structure: KnowledgeStructure, path: str | Path, *, format: LegacyFormat = "SRBT", items: Sequence[str] | None = None, ) -> None: """Write states in cardinality/label order; columns default to sorted items.""" labels = sorted(structure.domain) if items is None else list(items) if set(labels) != structure.domain: raise ValueError("items must be an ordering of the structure domain.") rows = [ [int(q in s) for q in labels] for s in sorted(structure.states, key=lambda s: (len(s), sorted(s))) ] write_legacy_matrix(labels, rows, path, kind="structure", format=format)
[docs] def read_legacy_relation( path: str | Path, *, format: Literal["SRBT", "matrix", "auto"] = "auto", items: Sequence[str] | None = None, ) -> SurmiseRelation: """Read a reflexive, transitive relation; no closure is added implicitly. Entry row a, column b equal to 1 means a is a prerequisite of b, matching kstMatrix/kstIO (columns are the minimal states for their items). """ labels, rows = read_legacy_matrix(path, kind="relation", format=format, items=items) if any(rows[i][i] != 1 for i in range(len(labels))): raise ValueError("Surmise relation must be reflexive.") relation = SurmiseRelation( labels, [(a, b) for i, a in enumerate(labels) for j, b in enumerate(labels) if rows[i][j]] ) if relation.transitive_closure().relations != relation.relations: raise ValueError("Surmise relation must be transitive.") return relation
[docs] def write_legacy_relation( relation: SurmiseRelation, path: str | Path, *, format: Literal["SRBT", "matrix"] = "SRBT", items: Sequence[str] | None = None, ) -> None: """Write a closed prerequisite relation; generators must be closed explicitly.""" labels = sorted(relation.items) if items is None else list(items) if set(labels) != relation.items: raise ValueError("items must be an ordering of the relation domain.") if relation.transitive_closure().relations != relation.relations: raise ValueError("Close the relation explicitly before exporting a surmise relation.") rows = [[int((a, b) in relation) for b in labels] for a in labels] write_legacy_matrix(labels, rows, path, kind="relation", format=format)
def _check_kind(kind: str) -> None: if kind not in (*_HEADERS, "family"): raise ValueError("Unknown legacy object kind.") def _check_format(kind: str, format: str) -> None: if format not in ("KST", "SRBT", "matrix"): raise ValueError("Unknown legacy format.") if (format == "SRBT" and kind == "family") or (format == "KST" and kind == "relation"): raise ValueError(f"{format} is not defined for {kind}.") def _validate_labels(labels: list[str], width: int) -> None: if ( not labels or len(labels) != width or any(not isinstance(q, str) for q in labels) or len(set(labels)) != len(labels) ): raise ValueError("items must be unique string labels matching the matrix width.") def _positive_integer(text: str) -> int: if not text or any(c not in "0123456789" for c in text) or int(text) < 1: raise ValueError("Legacy dimensions must be positive integers.") return int(text) def _parse(lines: list[str], kind: str, format: str) -> list[list[int]]: _check_format(kind, format) offset = 0 if format == "SRBT": headers = {_HEADERS[kind]} if kind in ("space", "structure"): headers.update({"#SRBT v2.0 space", "#SRBT v2.0 space ASCII"}) if lines[0] not in headers: raise ValueError("SRBT header does not match the expected object kind.") offset = 1 if format == "matrix": width, height = len(lines[0]), len(lines) else: dimensions = 1 if kind == "relation" else 2 if len(lines) < offset + dimensions: raise ValueError("Missing legacy dimensions.") width = _positive_integer(lines[offset]) height = width if kind == "relation" else _positive_integer(lines[offset + 1]) offset += dimensions if format == "SRBT": while offset < len(lines) and lines[offset].startswith("#"): offset += 1 rows = lines[offset:] if ( width < 1 or len(rows) != height or not rows or any(len(row) != width or any(c not in "01" for c in row) for row in rows) ): raise ValueError("Legacy matrix dimensions or binary entries are invalid.") if kind == "relation" and height != width: raise ValueError("Relation matrix must be square.") return [[int(c) for c in row] for row in rows]