witful 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. witful/__init__.py +5 -0
  2. witful/checkpoints/__init__.py +5 -0
  3. witful/checkpoints/cli.py +116 -0
  4. witful/checkpoints/diff.py +122 -0
  5. witful/checkpoints/journal.py +154 -0
  6. witful/checkpoints/models.py +67 -0
  7. witful/checkpoints/plotting.py +146 -0
  8. witful/checkpoints/postgres.py +75 -0
  9. witful/checkpoints/snapshot.py +71 -0
  10. witful/checkpoints/storage.py +219 -0
  11. witful/cli.py +56 -0
  12. witful/files.py +78 -0
  13. witful/knowledge/__init__.py +9 -0
  14. witful/knowledge/applications.py +140 -0
  15. witful/knowledge/cli.py +145 -0
  16. witful/knowledge/compiler.py +200 -0
  17. witful/knowledge/contracts.py +97 -0
  18. witful/knowledge/metrics.py +176 -0
  19. witful/knowledge/models.py +227 -0
  20. witful/knowledge/plotting.py +334 -0
  21. witful/knowledge/rendering.py +39 -0
  22. witful/lessons.py +226 -0
  23. witful/life/__init__.py +9 -0
  24. witful/life/cli.py +142 -0
  25. witful/life/contracts.py +138 -0
  26. witful/life/journal.py +198 -0
  27. witful/life/models.py +104 -0
  28. witful/life/postgres.py +145 -0
  29. witful/life/replay.py +172 -0
  30. witful/plotting.py +162 -0
  31. witful/privacy/__init__.py +5 -0
  32. witful/privacy/cli.py +53 -0
  33. witful/privacy/scanner.py +171 -0
  34. witful/project.py +58 -0
  35. witful/py.typed +0 -0
  36. witful/registry/__init__.py +5 -0
  37. witful/registry/names.py +338 -0
  38. witful/repertoire/__init__.py +5 -0
  39. witful/repertoire/cli.py +102 -0
  40. witful/repertoire/journal.py +133 -0
  41. witful/repertoire/models.py +87 -0
  42. witful/repertoire/plotting.py +94 -0
  43. witful/sql/postgres/.sqlfluff +20 -0
  44. witful/sql/postgres/checkpoints/.sqlfluff +5 -0
  45. witful/sql/postgres/checkpoints/insert.sql +2 -0
  46. witful/sql/postgres/checkpoints/schema.sql +12 -0
  47. witful/sql/postgres/checkpoints/select.sql +9 -0
  48. witful/sql/postgres/insert.sql +4 -0
  49. witful/sql/postgres/life/events.sql +7 -0
  50. witful/sql/postgres/life/insert-event.sql +2 -0
  51. witful/sql/postgres/life/insert-snapshot.sql +3 -0
  52. witful/sql/postgres/life/schema.sql +18 -0
  53. witful/sql/postgres/life/snapshots.sql +6 -0
  54. witful/sql/postgres/lock.sql +2 -0
  55. witful/sql/postgres/schema.sql +19 -0
  56. witful/sql/postgres/search.sql +23 -0
  57. witful/sql/sqlite/.sqlfluff +17 -0
  58. witful/sql/sqlite/checkpoints/.sqlfluff +5 -0
  59. witful/sql/sqlite/checkpoints/insert.sql +2 -0
  60. witful/sql/sqlite/checkpoints/lock.sql +3 -0
  61. witful/sql/sqlite/checkpoints/schema.sql +12 -0
  62. witful/sql/sqlite/checkpoints/select.sql +9 -0
  63. witful/sql/sqlite/insert.sql +2 -0
  64. witful/sql/sqlite/populate.sql +8 -0
  65. witful/sql/sqlite/schema.sql +15 -0
  66. witful/sql/sqlite/search.sql +21 -0
  67. witful/storage/__init__.py +166 -0
  68. witful/storage/cli.py +109 -0
  69. witful/storage/connection.py +52 -0
  70. witful/storage/exceptions.py +13 -0
  71. witful/storage/postgres.py +79 -0
  72. witful-0.1.3.dist-info/METADATA +82 -0
  73. witful-0.1.3.dist-info/RECORD +76 -0
  74. witful-0.1.3.dist-info/WHEEL +4 -0
  75. witful-0.1.3.dist-info/entry_points.txt +4 -0
  76. witful-0.1.3.dist-info/licenses/LICENSE +674 -0
witful/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Curate portable preferences, sourced teachings, and observed reuse through Witful.
3
+ """
4
+
5
+ from __future__ import annotations
@@ -0,0 +1,5 @@
1
+ """
2
+ Compare explicit project boundaries using immutable snapshots of documented evidence.
3
+ """
4
+
5
+ from __future__ import annotations
@@ -0,0 +1,116 @@
1
+ """
2
+ Record, compare, mirror, and render explicit project checkpoints.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from jsonschema import ValidationError
15
+
16
+ from witful.checkpoints.diff import compare
17
+ from witful.checkpoints.journal import read, record
18
+ from witful.checkpoints.models import Checkpoint
19
+ from witful.checkpoints.plotting import build
20
+ from witful.checkpoints.storage import SQLiteCheckpointStore, open_store
21
+ from witful.files import replace_file
22
+ from witful.knowledge.contracts import as_data, schema
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Sequence
26
+
27
+ __all__ = ["main"]
28
+
29
+
30
+ def main(argv: Sequence[str] | None = None) -> int:
31
+ """
32
+ Execute one bounded operation, keeping remote writes explicit and plots out of recording.
33
+
34
+ Args:
35
+ argv (Sequence[str] | None): Explicit arguments or process command line.
36
+
37
+ Returns:
38
+ int: Zero on success; parser exit two for invalid data, references, or unavailable storage.
39
+ """
40
+ parser = argparse.ArgumentParser(description=__doc__)
41
+ parser.add_argument("--repo", type=Path, default=Path.cwd())
42
+ parser.add_argument("--namespace", default=os.environ.get("WITFUL_DATABASE_NAMESPACE", "default"))
43
+ parser.add_argument("--database", type=Path, help="Local SQLite mirror, default .witful/checkpoints.sqlite3")
44
+ commands = parser.add_subparsers(dest="command", required=True)
45
+ capture = commands.add_parser("record", help="Snapshot reviewed evidence at a project's committed HEAD")
46
+ capture.add_argument("--project", type=Path, required=True)
47
+ capture.add_argument("--project-id", required=True, help="Stable chosen alias; remote URLs and machine paths are never inferred")
48
+ commands.add_parser("check", help="Validate the journal without creating data or plots")
49
+ commands.add_parser("schemas", help="Generate the authoritative checkpoint schema")
50
+ commands.add_parser("sync", help="Seed the SQLite mirror, or explicitly selected WITFUL_DATABASE_URL")
51
+ difference = commands.add_parser("diff", help="Compare two snapshots, defaulting to the last two checkpoints")
52
+ difference.add_argument("--before")
53
+ difference.add_argument("--after")
54
+ difference.add_argument("--from-database", action="store_true")
55
+ render = commands.add_parser("build", help="Compute all differences and refresh recent-project plots")
56
+ render.add_argument("--from-database", action="store_true")
57
+ render.add_argument("--output-dir", type=Path, default=Path("docs/assets"))
58
+ render.add_argument("--limit", type=int, default=8)
59
+ args = parser.parse_args(argv)
60
+ root = args.repo.resolve()
61
+ database = args.database or root / ".witful/checkpoints.sqlite3"
62
+
63
+ try:
64
+ if args.command == "schemas":
65
+ path = root / "knowledge/schemas/checkpoint.schema.json"
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ content = (json.dumps(schema(Checkpoint), indent=2, sort_keys=True) + "\n").encode()
68
+ replace_file(path, content, expected=path.read_bytes() if path.exists() else None)
69
+ return 0
70
+
71
+ if args.command == "record":
72
+ event = record(root, args.project, args.project_id)
73
+
74
+ # Journal persistence precedes the disposable local mirror; an interrupted sync is safe to retry.
75
+ SQLiteCheckpointStore(database, args.namespace).seed(read(root / "knowledge/checkpoints.jsonl"))
76
+ print(event.id)
77
+ return 0
78
+
79
+ stored = args.command in {"build", "diff"} and args.from_database
80
+ events = (
81
+ open_store(database, os.environ.get("WITFUL_DATABASE_URL"), args.namespace).load()
82
+ if stored
83
+ else read(root / "knowledge/checkpoints.jsonl")
84
+ )
85
+
86
+ if args.command == "check":
87
+ print(f"Validated {len(events)} project checkpoints")
88
+ elif args.command == "sync":
89
+ print(
90
+ json.dumps({"added_checkpoints": open_store(database, os.environ.get("WITFUL_DATABASE_URL"), args.namespace).seed(events)})
91
+ )
92
+ elif args.command == "diff":
93
+ if len(events) < 2 and (not args.before or not args.after):
94
+ raise ValueError("Record two checkpoints or supply two existing checkpoint IDs")
95
+
96
+ indexed = {event.id: event for event in events}
97
+ before = indexed.get(args.before) if args.before else events[-2]
98
+ after = indexed.get(args.after) if args.after else events[-1]
99
+
100
+ if before is None or after is None:
101
+ raise ValueError("Unknown checkpoint ID")
102
+
103
+ print(json.dumps([as_data(item) for item in compare(before, after)], indent=2))
104
+ else:
105
+ output = args.output_dir if args.output_dir.is_absolute() else root / args.output_dir
106
+
107
+ for path in build(events, output, args.limit):
108
+ print(path)
109
+ except (OSError, ValueError, RuntimeError, ValidationError, subprocess.TimeoutExpired):
110
+ parser.exit(
111
+ 2,
112
+ "Checkpoint operation failed; validate source records, project HEAD, requested IDs, and storage access. "
113
+ "No source text or credentials were logged.\n",
114
+ )
115
+
116
+ return 0
@@ -0,0 +1,122 @@
1
+ """
2
+ Compare checkpoint evidence using stable IDs rather than subtracting aggregate counts.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from attrs import frozen
10
+
11
+ from witful.checkpoints.models import SkillState
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ from witful.checkpoints.models import Checkpoint, Fact
17
+
18
+ __all__ = ["Change", "SkillDiff", "compare", "visits"]
19
+
20
+
21
+ @frozen
22
+ class Change:
23
+ """
24
+ Distinguish new evidence from revisions and withdrawals.
25
+
26
+ Attributes:
27
+ added (tuple[str, ...]): Keys present only in the later snapshot.
28
+ removed (tuple[str, ...]): Keys present only in the earlier snapshot.
29
+ revised (tuple[str, ...]): Shared keys with changed content fingerprints.
30
+ """
31
+
32
+ added: tuple[str, ...] = ()
33
+ removed: tuple[str, ...] = ()
34
+ revised: tuple[str, ...] = ()
35
+
36
+
37
+ @frozen
38
+ class SkillDiff:
39
+ """
40
+ Describe one changed skill without interpreting its counts as competence.
41
+
42
+ Attributes:
43
+ id (str): Stable skill ID.
44
+ name (str): Most recent label, or the earlier one for a removed skill.
45
+ status (str): Added, removed, renamed, or retained skill.
46
+ lessons (Change): Documented teaching changes; classification changes remain observable.
47
+ uses (Change): Actual task/context observations added, removed, or revised.
48
+ """
49
+
50
+ id: str
51
+ name: str
52
+ status: str
53
+ lessons: Change
54
+ uses: Change
55
+
56
+
57
+ def _change(before: tuple[Fact, ...], after: tuple[Fact, ...]) -> Change:
58
+ """
59
+ Derive exact set differences and shared-key revisions.
60
+
61
+ Args:
62
+ before (tuple[Fact, ...]): Earlier validated facts.
63
+ after (tuple[Fact, ...]): Later validated facts.
64
+
65
+ Returns:
66
+ Change: Canonical keys grouped by their observed change type.
67
+ """
68
+ left = {fact.id: fact.digest for fact in before}
69
+ right = {fact.id: fact.digest for fact in after}
70
+ return Change(
71
+ tuple(sorted(right.keys() - left.keys())),
72
+ tuple(sorted(left.keys() - right.keys())),
73
+ tuple(sorted(key for key in left.keys() & right.keys() if left[key] != right[key])),
74
+ )
75
+
76
+
77
+ def compare(before: Checkpoint, after: Checkpoint) -> tuple[SkillDiff, ...]:
78
+ """
79
+ Compare any two stored snapshots without loading current lessons or application records.
80
+
81
+ Args:
82
+ before (Checkpoint): Earlier or explicitly chosen baseline.
83
+ after (Checkpoint): Comparison target.
84
+
85
+ Returns:
86
+ tuple[SkillDiff, ...]: Only changed skills, sorted by stable ID; identical evidence yields no differences.
87
+ """
88
+ left = {state.id: state for state in before.skills}
89
+ right = {state.id: state for state in after.skills}
90
+ result: list[SkillDiff] = []
91
+
92
+ for key in sorted(left.keys() | right.keys()):
93
+ prior = left.get(key, SkillState(key, right[key].name)) if key not in left else left[key]
94
+ current = right.get(key, SkillState(key, prior.name))
95
+ status = "added" if key not in left else "removed" if key not in right else "renamed" if prior.name != current.name else "retained"
96
+ item = SkillDiff(key, current.name, status, _change(prior.lessons, current.lessons), _change(prior.uses, current.uses))
97
+
98
+ if status != "retained" or item.lessons != Change() or item.uses != Change():
99
+ result.append(item)
100
+
101
+ return tuple(result)
102
+
103
+
104
+ def visits(events: Sequence[Checkpoint]) -> tuple[Checkpoint, ...]:
105
+ """
106
+ Collapse consecutive commits in one project to the final state of that visit.
107
+
108
+ Args:
109
+ events (Sequence[Checkpoint]): Validated journal in append order.
110
+
111
+ Returns:
112
+ tuple[Checkpoint, ...]: Visit endpoints in chronological order; returning to a project creates another visit.
113
+ """
114
+ result: list[Checkpoint] = []
115
+
116
+ for event in events:
117
+ if result and result[-1].project == event.project:
118
+ result[-1] = event
119
+ else:
120
+ result.append(event)
121
+
122
+ return tuple(result)
@@ -0,0 +1,154 @@
1
+ """
2
+ Append explicit Git boundaries with a validated hash chain and idempotent retry semantics.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import fcntl
8
+ import json
9
+ import re
10
+ import subprocess
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
+
14
+ from attrs import evolve
15
+
16
+ from witful.checkpoints.models import Checkpoint
17
+ from witful.checkpoints.snapshot import digest, snapshot
18
+ from witful.files import replace_file
19
+ from witful.knowledge.applications import read_applications
20
+ from witful.knowledge.compiler import compile_identity
21
+ from witful.knowledge.contracts import as_data, structure
22
+ from witful.knowledge.models import Catalog
23
+ from witful.lessons import read_journal
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Sequence
27
+ from pathlib import Path
28
+
29
+ __all__ = ["checkpoint_id", "read", "record", "validate"]
30
+ _HASH = re.compile(r"[0-9a-f]{64}")
31
+
32
+
33
+ def checkpoint_id(event: Checkpoint) -> str:
34
+ """
35
+ Bind project metadata and all snapshot fields to an immutable identifier.
36
+
37
+ Args:
38
+ event (Checkpoint): Record whose current ID is ignored.
39
+
40
+ Returns:
41
+ str: Canonical content digest.
42
+ """
43
+ value = as_data(event)
44
+ value.pop("id")
45
+ return digest(value)
46
+
47
+
48
+ def validate(events: Sequence[Checkpoint]) -> None:
49
+ """
50
+ Reject broken chronology, altered content, duplicate facts, and ambiguous states.
51
+
52
+ Args:
53
+ events (Sequence[Checkpoint]): Complete ordered history.
54
+
55
+ Returns:
56
+ None: The hash chain and every snapshot meet the checkpoint contract.
57
+
58
+ Raises:
59
+ ValueError: A record violates a structural or chronological invariant.
60
+ """
61
+ previous: str | None = None
62
+ seen: set[str] = set()
63
+
64
+ for event in events:
65
+ if event.id != checkpoint_id(event) or event.id in seen or event.previous != previous:
66
+ raise ValueError("Invalid checkpoint content or history chain")
67
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}", event.project) or ".." in event.project.split("/"):
68
+ raise ValueError("Project aliases require 1-128 letters, digits, dots, slashes, underscores, or hyphens")
69
+ if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", event.revision):
70
+ raise ValueError("Checkpoint revision must be a full Git commit ID")
71
+ if datetime.fromisoformat(event.recorded_at).tzinfo is None:
72
+ raise ValueError("Checkpoint recording time must include a timezone")
73
+ identifiers = [state.id for state in event.skills]
74
+
75
+ if identifiers != sorted(set(identifiers)) or any(not state.id.strip() or not state.name.strip() for state in event.skills):
76
+ raise ValueError("Checkpoint skills require sorted unique IDs and nonempty names")
77
+
78
+ for state in event.skills:
79
+ for facts in (state.lessons, state.uses):
80
+ keys = [fact.id for fact in facts]
81
+
82
+ if keys != sorted(set(keys)) or any(not fact.id.strip() or not _HASH.fullmatch(fact.digest) for fact in facts):
83
+ raise ValueError("Checkpoint facts require unique sorted keys and SHA-256 digests")
84
+
85
+ previous = event.id
86
+ seen.add(event.id)
87
+
88
+
89
+ def read(path: Path) -> tuple[Checkpoint, ...]:
90
+ """
91
+ Read an empty or existing source journal without creating a database.
92
+
93
+ Args:
94
+ path (Path): Collection-owned checkpoint JSONL file.
95
+
96
+ Returns:
97
+ tuple[Checkpoint, ...]: Complete validated history; absence means no recorded boundaries.
98
+ """
99
+ events = (
100
+ tuple(structure(json.loads(line), Checkpoint) for line in path.read_text().splitlines() if line.strip()) if path.exists() else ()
101
+ )
102
+ validate(events)
103
+ return events
104
+
105
+
106
+ def record(root: Path, project: Path, alias: str) -> Checkpoint:
107
+ """
108
+ Append one explicit project boundary using current reviewed collection inputs.
109
+
110
+ Args:
111
+ root (Path): Collection containing lessons and application journals.
112
+ project (Path): Existing Git checkout to identify, without reading its source or remote configuration.
113
+ alias (str): Stable project identifier selected by the caller.
114
+
115
+ Returns:
116
+ Checkpoint: Newly appended record or the last record for an identical immediate retry.
117
+
118
+ Raises:
119
+ ValueError: Git has no committed HEAD or the source evidence is invalid.
120
+ RuntimeError: A source journal changes concurrently with the atomic replacement.
121
+ """
122
+ result = subprocess.run(
123
+ ["git", "-C", str(project.resolve()), "rev-parse", "--verify", "HEAD^{commit}"],
124
+ capture_output=True,
125
+ text=True,
126
+ check=False,
127
+ timeout=10,
128
+ )
129
+
130
+ if result.returncode:
131
+ raise ValueError("Checkpoint project must be an existing Git checkout with a committed HEAD")
132
+
133
+ # Reuse the collection's writer lock so application updates cannot race snapshot compilation.
134
+ with (root / ".applications.lock").open("a") as lock:
135
+ fcntl.flock(lock, fcntl.LOCK_EX)
136
+ path = root / "knowledge/checkpoints.jsonl"
137
+ original = path.read_bytes() if path.exists() else None
138
+ events = read(path)
139
+ catalog = structure(json.loads((root / "knowledge/catalog.json").read_text()), Catalog)
140
+ identity = compile_identity(read_journal(root / "lessons.jsonl"), catalog)
141
+ skills = snapshot(identity, read_applications(root / "knowledge/applications.jsonl"))
142
+ revision = result.stdout.strip()
143
+
144
+ # Returning to a project after another visit is a new boundary, even at the same commit.
145
+ if events and (events[-1].project, events[-1].revision, events[-1].skills) == (alias, revision, skills):
146
+ return events[-1]
147
+
148
+ event = Checkpoint("", events[-1].id if events else None, alias, revision, datetime.now(UTC).isoformat(), skills)
149
+ event = evolve(event, id=checkpoint_id(event))
150
+ validate((*events, event))
151
+ content = (original or b"").rstrip(b"\n")
152
+ content += (b"\n" if content else b"") + json.dumps(as_data(event), sort_keys=True).encode() + b"\n"
153
+ replace_file(path, content, expected=original)
154
+ return event
@@ -0,0 +1,67 @@
1
+ """
2
+ Define portable checkpoint snapshots independently of Git paths and storage engines.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Literal
8
+
9
+ from attrs import frozen
10
+
11
+ __all__ = ["Checkpoint", "Fact", "SkillState"]
12
+
13
+
14
+ @frozen
15
+ class Fact:
16
+ """
17
+ Identify a documented item and its revision without copying its text into every checkpoint.
18
+
19
+ Attributes:
20
+ id (str): Stable item key; application keys hash their task and context.
21
+ digest (str): SHA-256 of the corresponding canonical source object.
22
+ """
23
+
24
+ id: str
25
+ digest: str
26
+
27
+
28
+ @frozen
29
+ class SkillState:
30
+ """
31
+ Snapshot one skill's current teachings and explicitly recorded applications.
32
+
33
+ Attributes:
34
+ id (str): Stable skill ID.
35
+ name (str): Reviewed display name at this boundary.
36
+ lessons (tuple[Fact, ...]): Active lessons, including their lemma annotations in the digest.
37
+ uses (tuple[Fact, ...]): Latest observation for each task/context reaching this skill.
38
+ """
39
+
40
+ id: str
41
+ name: str
42
+ lessons: tuple[Fact, ...] = ()
43
+ uses: tuple[Fact, ...] = ()
44
+
45
+
46
+ @frozen
47
+ class Checkpoint:
48
+ """
49
+ Mark a project visit or commit without asserting that the project caused the observed changes.
50
+
51
+ Attributes:
52
+ id (str): SHA-256 of the complete record except this field.
53
+ previous (str | None): Prior checkpoint ID, giving an explicit chronology.
54
+ project (str): User-selected stable public or private alias, never an inferred remote URL.
55
+ revision (str): Exact Git commit at recording time.
56
+ recorded_at (str): Timezone-aware recording time, independent of Git author dates.
57
+ skills (tuple[SkillState, ...]): Complete canonical state, sufficient to recompute comparisons.
58
+ version (Literal[1]): Supported source schema version.
59
+ """
60
+
61
+ id: str
62
+ previous: str | None
63
+ project: str
64
+ revision: str
65
+ recorded_at: str
66
+ skills: tuple[SkillState, ...] = ()
67
+ version: Literal[1] = 1
@@ -0,0 +1,146 @@
1
+ """
2
+ Render recent project visits and cumulative comparisons without implying proficiency scores.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import TYPE_CHECKING
9
+
10
+ import matplotlib as mpl
11
+ from matplotlib.figure import Figure
12
+
13
+ from witful.checkpoints.diff import compare, visits
14
+ from witful.checkpoints.snapshot import digest
15
+ from witful.files import replace_file
16
+ from witful.knowledge.contracts import as_data
17
+ from witful.knowledge.plotting import save_figure
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+ from witful.checkpoints.models import Checkpoint
24
+
25
+ __all__ = ["build"]
26
+
27
+
28
+ def _figure(pairs: Sequence[tuple[Checkpoint, Checkpoint]], title: str, question: str) -> Figure:
29
+ """
30
+ Show sparse signed additions, withdrawals, and revisions for each changed skill.
31
+
32
+ Args:
33
+ pairs (Sequence[tuple[Checkpoint, Checkpoint]]): Baseline/target pairs in newest-first display order.
34
+ title (str): Short plot title.
35
+ question (str): Question answered by this view.
36
+
37
+ Returns:
38
+ Figure: Annotated matrix with zero-change cells muted and no inferred initial growth.
39
+ """
40
+ differences = [compare(before, after) for before, after in pairs]
41
+ names = {item.id: item.name for group in reversed(differences) for item in group}
42
+ keys = sorted(names, key=lambda key: (names[key].casefold(), key))
43
+ figure = Figure(figsize=(max(10, min(30, 5 + 2 * len(pairs))), max(4.5, 2.7 + len(keys) * 0.65)), layout="constrained")
44
+ figure.suptitle(title + "\n" + question, fontsize=16)
45
+ axis = figure.subplots()
46
+
47
+ if not keys or not pairs:
48
+ message = (
49
+ "No changed skill evidence between these boundaries" if pairs else "Record a second project visit to compare with the baseline"
50
+ )
51
+ axis.text(0.5, 0.5, message, transform=axis.transAxes, ha="center", va="center", color="#586774")
52
+ axis.set_axis_off()
53
+ return figure
54
+
55
+ # Cell brightness describes change volume; text retains direction and revisions separately.
56
+ cells = [[0 for _ in pairs] for _ in keys]
57
+ annotations: dict[tuple[int, int], str] = {}
58
+
59
+ for column, group in enumerate(differences):
60
+ for item in group:
61
+ row = keys.index(item.id)
62
+ learning, use = item.lessons, item.uses
63
+ size = sum(len(values) for change in (learning, use) for values in (change.added, change.removed, change.revised))
64
+ cells[row][column] = max(size, int(item.status != "retained"))
65
+ annotations[row, column] = (
66
+ f"L +{len(learning.added)} / -{len(learning.removed)} / ~{len(learning.revised)}\n"
67
+ f"U +{len(use.added)} / -{len(use.removed)} / ~{len(use.revised)}"
68
+ + (f"\n{item.status}" if item.status != "retained" else "")
69
+ )
70
+
71
+ maximum = max(max(row) for row in cells)
72
+ axis.imshow(cells, cmap="Blues", vmin=0, vmax=max(2, maximum * 2), aspect="auto")
73
+ axis.set_yticks(range(len(keys)), [names[key] for key in keys], fontsize=10)
74
+ axis.set_xticks(
75
+ range(len(pairs)),
76
+ [f"{after.project} @ {after.revision[:7]}\nvs {before.project} @ {before.revision[:7]}" for before, after in pairs],
77
+ fontsize=9,
78
+ rotation=20,
79
+ ha="right",
80
+ )
81
+ axis.tick_params(length=0)
82
+
83
+ for (row, column), text in annotations.items():
84
+ axis.text(column, row, text, ha="center", va="center", fontsize=9, color="#193448")
85
+
86
+ axis.set_xlabel("L = documented lessons; U = observed uses; + added / - removed / ~ revised. Counts are not mastery.", labelpad=14)
87
+ return figure
88
+
89
+
90
+ def build(events: Sequence[Checkpoint], destination: Path, limit: int = 8) -> tuple[Path, ...]:
91
+ """
92
+ Export full comparisons and bounded plots ordered by recorded project visits.
93
+
94
+ Args:
95
+ events (Sequence[Checkpoint]): Validated complete checkpoint history.
96
+ destination (Path): Generated artifact directory.
97
+ limit (int): Maximum recent comparisons shown in each plot, from one through twenty.
98
+
99
+ Returns:
100
+ tuple[Path, ...]: Complete JSON export and two SVG/PNG views.
101
+
102
+ Raises:
103
+ ValueError: The plot limit is outside the documented range.
104
+ """
105
+ if not 1 <= limit <= 20:
106
+ raise ValueError("Checkpoint plot limit must be between one and twenty")
107
+
108
+ endpoints = visits(events)
109
+ adjacent = list(reversed(list(zip(endpoints[:-1], endpoints[1:], strict=True))))
110
+ latest = [(before, endpoints[-1]) for before in reversed(endpoints[:-1])] if endpoints else []
111
+ commits = list(zip(events[:-1], events[1:], strict=True))
112
+ destination.mkdir(parents=True, exist_ok=True)
113
+ payload = {
114
+ "checkpoints": [as_data(event) for event in events],
115
+ "commit_diffs": [
116
+ {"before": before.id, "after": after.id, "skills": [as_data(item) for item in compare(before, after)]}
117
+ for before, after in commits
118
+ ],
119
+ "visit_endpoints": [event.id for event in endpoints],
120
+ "latest_vs_previous_visits": [
121
+ {"before": before.id, "after": after.id, "skills": [as_data(item) for item in compare(before, after)]}
122
+ for before, after in latest
123
+ ],
124
+ }
125
+ path = destination / "checkpoints.json"
126
+ replace_file(
127
+ path, (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode(), expected=path.read_bytes() if path.exists() else None
128
+ )
129
+ outputs = [path]
130
+
131
+ with mpl.rc_context(mpl.rcParamsDefault):
132
+ mpl.rcParams.update({"font.family": "DejaVu Sans", "svg.fonttype": "none", "svg.hashsalt": digest(payload)})
133
+
134
+ for name, pairs, title, question in (
135
+ ("checkpoint-transitions", adjacent, "Project chapters", "What changed between consecutive project visits?"),
136
+ (
137
+ "checkpoint-comparisons",
138
+ latest,
139
+ "Current chapter in context",
140
+ "How does the latest recorded state differ from earlier project visits?",
141
+ ),
142
+ ):
143
+ figure = _figure(pairs[:limit], title, question)
144
+ outputs.extend(save_figure(figure, destination, name))
145
+
146
+ return tuple(outputs)