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.
- witful/__init__.py +5 -0
- witful/checkpoints/__init__.py +5 -0
- witful/checkpoints/cli.py +116 -0
- witful/checkpoints/diff.py +122 -0
- witful/checkpoints/journal.py +154 -0
- witful/checkpoints/models.py +67 -0
- witful/checkpoints/plotting.py +146 -0
- witful/checkpoints/postgres.py +75 -0
- witful/checkpoints/snapshot.py +71 -0
- witful/checkpoints/storage.py +219 -0
- witful/cli.py +56 -0
- witful/files.py +78 -0
- witful/knowledge/__init__.py +9 -0
- witful/knowledge/applications.py +140 -0
- witful/knowledge/cli.py +145 -0
- witful/knowledge/compiler.py +200 -0
- witful/knowledge/contracts.py +97 -0
- witful/knowledge/metrics.py +176 -0
- witful/knowledge/models.py +227 -0
- witful/knowledge/plotting.py +334 -0
- witful/knowledge/rendering.py +39 -0
- witful/lessons.py +226 -0
- witful/life/__init__.py +9 -0
- witful/life/cli.py +142 -0
- witful/life/contracts.py +138 -0
- witful/life/journal.py +198 -0
- witful/life/models.py +104 -0
- witful/life/postgres.py +145 -0
- witful/life/replay.py +172 -0
- witful/plotting.py +162 -0
- witful/privacy/__init__.py +5 -0
- witful/privacy/cli.py +53 -0
- witful/privacy/scanner.py +171 -0
- witful/project.py +58 -0
- witful/py.typed +0 -0
- witful/registry/__init__.py +5 -0
- witful/registry/names.py +338 -0
- witful/repertoire/__init__.py +5 -0
- witful/repertoire/cli.py +102 -0
- witful/repertoire/journal.py +133 -0
- witful/repertoire/models.py +87 -0
- witful/repertoire/plotting.py +94 -0
- witful/sql/postgres/.sqlfluff +20 -0
- witful/sql/postgres/checkpoints/.sqlfluff +5 -0
- witful/sql/postgres/checkpoints/insert.sql +2 -0
- witful/sql/postgres/checkpoints/schema.sql +12 -0
- witful/sql/postgres/checkpoints/select.sql +9 -0
- witful/sql/postgres/insert.sql +4 -0
- witful/sql/postgres/life/events.sql +7 -0
- witful/sql/postgres/life/insert-event.sql +2 -0
- witful/sql/postgres/life/insert-snapshot.sql +3 -0
- witful/sql/postgres/life/schema.sql +18 -0
- witful/sql/postgres/life/snapshots.sql +6 -0
- witful/sql/postgres/lock.sql +2 -0
- witful/sql/postgres/schema.sql +19 -0
- witful/sql/postgres/search.sql +23 -0
- witful/sql/sqlite/.sqlfluff +17 -0
- witful/sql/sqlite/checkpoints/.sqlfluff +5 -0
- witful/sql/sqlite/checkpoints/insert.sql +2 -0
- witful/sql/sqlite/checkpoints/lock.sql +3 -0
- witful/sql/sqlite/checkpoints/schema.sql +12 -0
- witful/sql/sqlite/checkpoints/select.sql +9 -0
- witful/sql/sqlite/insert.sql +2 -0
- witful/sql/sqlite/populate.sql +8 -0
- witful/sql/sqlite/schema.sql +15 -0
- witful/sql/sqlite/search.sql +21 -0
- witful/storage/__init__.py +166 -0
- witful/storage/cli.py +109 -0
- witful/storage/connection.py +52 -0
- witful/storage/exceptions.py +13 -0
- witful/storage/postgres.py +79 -0
- witful-0.1.3.dist-info/METADATA +82 -0
- witful-0.1.3.dist-info/RECORD +76 -0
- witful-0.1.3.dist-info/WHEEL +4 -0
- witful-0.1.3.dist-info/entry_points.txt +4 -0
- witful-0.1.3.dist-info/licenses/LICENSE +674 -0
witful/__init__.py
ADDED
|
@@ -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)
|