anagrapheus 0.1.1__tar.gz

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 (64) hide show
  1. anagrapheus-0.1.1/PKG-INFO +89 -0
  2. anagrapheus-0.1.1/README.md +72 -0
  3. anagrapheus-0.1.1/pyproject.toml +89 -0
  4. anagrapheus-0.1.1/pyproject.toml.orig +92 -0
  5. anagrapheus-0.1.1/src/anagrapheus/__init__.py +45 -0
  6. anagrapheus-0.1.1/src/anagrapheus/assembly/__init__.py +81 -0
  7. anagrapheus-0.1.1/src/anagrapheus/assembly/assemble.py +202 -0
  8. anagrapheus-0.1.1/src/anagrapheus/assembly/errors.py +41 -0
  9. anagrapheus-0.1.1/src/anagrapheus/assembly/parts.py +167 -0
  10. anagrapheus-0.1.1/src/anagrapheus/assembly/render.py +40 -0
  11. anagrapheus-0.1.1/src/anagrapheus/assembly/slices.py +374 -0
  12. anagrapheus-0.1.1/src/anagrapheus/beliefs/__init__.py +120 -0
  13. anagrapheus-0.1.1/src/anagrapheus/beliefs/divergence.py +53 -0
  14. anagrapheus-0.1.1/src/anagrapheus/beliefs/errors.py +102 -0
  15. anagrapheus-0.1.1/src/anagrapheus/beliefs/events.py +175 -0
  16. anagrapheus-0.1.1/src/anagrapheus/beliefs/fold.py +267 -0
  17. anagrapheus-0.1.1/src/anagrapheus/beliefs/index.py +56 -0
  18. anagrapheus-0.1.1/src/anagrapheus/beliefs/migrations.py +44 -0
  19. anagrapheus-0.1.1/src/anagrapheus/beliefs/ops.py +131 -0
  20. anagrapheus-0.1.1/src/anagrapheus/beliefs/predicates.py +317 -0
  21. anagrapheus-0.1.1/src/anagrapheus/beliefs/rekey.py +411 -0
  22. anagrapheus-0.1.1/src/anagrapheus/beliefs/sqlite.py +139 -0
  23. anagrapheus-0.1.1/src/anagrapheus/beliefs/store.py +58 -0
  24. anagrapheus-0.1.1/src/anagrapheus/beliefs/writer.py +135 -0
  25. anagrapheus-0.1.1/src/anagrapheus/chronicle/__init__.py +88 -0
  26. anagrapheus-0.1.1/src/anagrapheus/chronicle/errors.py +36 -0
  27. anagrapheus-0.1.1/src/anagrapheus/chronicle/fold.py +328 -0
  28. anagrapheus-0.1.1/src/anagrapheus/chronicle/migrations.py +39 -0
  29. anagrapheus-0.1.1/src/anagrapheus/chronicle/reading.py +171 -0
  30. anagrapheus-0.1.1/src/anagrapheus/chronicle/reconsolidation.py +144 -0
  31. anagrapheus-0.1.1/src/anagrapheus/chronicle/sections.py +62 -0
  32. anagrapheus-0.1.1/src/anagrapheus/chronicle/sqlite.py +283 -0
  33. anagrapheus-0.1.1/src/anagrapheus/chronicle/store.py +117 -0
  34. anagrapheus-0.1.1/src/anagrapheus/identity/__init__.py +105 -0
  35. anagrapheus-0.1.1/src/anagrapheus/identity/decisions.py +271 -0
  36. anagrapheus-0.1.1/src/anagrapheus/identity/errors.py +84 -0
  37. anagrapheus-0.1.1/src/anagrapheus/identity/events.py +184 -0
  38. anagrapheus-0.1.1/src/anagrapheus/identity/fold.py +83 -0
  39. anagrapheus-0.1.1/src/anagrapheus/identity/ladder.py +69 -0
  40. anagrapheus-0.1.1/src/anagrapheus/identity/migrations.py +117 -0
  41. anagrapheus-0.1.1/src/anagrapheus/identity/registry.py +153 -0
  42. anagrapheus-0.1.1/src/anagrapheus/identity/sqlite.py +522 -0
  43. anagrapheus-0.1.1/src/anagrapheus/identity/store.py +157 -0
  44. anagrapheus-0.1.1/src/anagrapheus/identity/vocabulary.py +149 -0
  45. anagrapheus-0.1.1/src/anagrapheus/provider/__init__.py +47 -0
  46. anagrapheus-0.1.1/src/anagrapheus/provider/errors.py +9 -0
  47. anagrapheus-0.1.1/src/anagrapheus/provider/model.py +309 -0
  48. anagrapheus-0.1.1/src/anagrapheus/provider/policy.py +274 -0
  49. anagrapheus-0.1.1/src/anagrapheus/provider/stream.py +82 -0
  50. anagrapheus-0.1.1/src/anagrapheus/provider/tools.py +183 -0
  51. anagrapheus-0.1.1/src/anagrapheus/py.typed +0 -0
  52. anagrapheus-0.1.1/src/anagrapheus/timeline/__init__.py +110 -0
  53. anagrapheus-0.1.1/src/anagrapheus/timeline/_canonical.py +82 -0
  54. anagrapheus-0.1.1/src/anagrapheus/timeline/envelope.py +78 -0
  55. anagrapheus-0.1.1/src/anagrapheus/timeline/errors.py +41 -0
  56. anagrapheus-0.1.1/src/anagrapheus/timeline/folds.py +544 -0
  57. anagrapheus-0.1.1/src/anagrapheus/timeline/migrations.py +142 -0
  58. anagrapheus-0.1.1/src/anagrapheus/timeline/pins.py +459 -0
  59. anagrapheus-0.1.1/src/anagrapheus/timeline/recording.py +136 -0
  60. anagrapheus-0.1.1/src/anagrapheus/timeline/registry.py +125 -0
  61. anagrapheus-0.1.1/src/anagrapheus/timeline/replay.py +342 -0
  62. anagrapheus-0.1.1/src/anagrapheus/timeline/sqlite.py +260 -0
  63. anagrapheus-0.1.1/src/anagrapheus/timeline/store.py +130 -0
  64. anagrapheus-0.1.1/src/anagrapheus/timeline/upcast.py +145 -0
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: anagrapheus
3
+ Version: 0.1.1
4
+ Summary: Event-sourced timeline, belief store, and identity layer for long-running agents.
5
+ Author: Anentropic
6
+ Author-email: Anentropic <ego@anentropic.com>
7
+ Requires-Dist: anyio>=4.0
8
+ Requires-Dist: doxastica>=0.2
9
+ Requires-Dist: pydantic>=2.11
10
+ Requires-Dist: pydantic-ai-slim>=2
11
+ Requires-Dist: pydantic-ai-slim[anthropic]>=2 ; extra == 'anthropic'
12
+ Requires-Dist: pydantic-ai-slim[openai]>=2 ; extra == 'openai'
13
+ Requires-Python: >=3.14
14
+ Provides-Extra: anthropic
15
+ Provides-Extra: openai
16
+ Description-Content-Type: text/markdown
17
+
18
+ # anagrapheus
19
+
20
+ [![CI](https://github.com/anentropic/anagrapheus/actions/workflows/ci.yml/badge.svg)](https://github.com/anentropic/anagrapheus/actions/workflows/ci.yml)
21
+ [![PyPI](https://img.shields.io/pypi/v/anagrapheus)](https://pypi.org/project/anagrapheus/)
22
+ [![Python](https://img.shields.io/badge/python-3.14%2B-blue)](https://www.python.org/)
23
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue)](https://github.com/anentropic/anagrapheus/blob/main/LICENSE)
24
+
25
+ An event-sourced timeline, belief store and identity layer for long-running LLM agents. One append-only event log is the authoritative record of a run; per-agent beliefs, entity identity, a consolidated history and assembled prompts are all projections of it that can be dropped and rebuilt. Every model call made through `PinnedModel`, and every tool wrapped with `pinned_tool`, is pinned to the log, so a recorded run replays offline, exactly, without reaching the model, and a run whose prompts have changed reports where it diverged instead of quietly drawing new answers.
26
+
27
+ It is for Python developers building agents and simulations that run for many turns and hold private beliefs about each other: negotiations, games, interactive fiction. It builds on [pydantic-ai](https://ai.pydantic.dev/) for the model call and works with any provider pydantic-ai supports.
28
+
29
+ ## Installation
30
+
31
+ anagrapheus needs Python 3.14 or later.
32
+
33
+ ```bash
34
+ pip install anagrapheus # or: uv add anagrapheus
35
+ ```
36
+
37
+ The base install runs offline. To call a real provider, add its extra: `anagrapheus[anthropic]` or `anagrapheus[openai]` (the OpenAI SDK, also used for OpenAI-compatible routes such as OpenRouter).
38
+
39
+ ## Documentation
40
+
41
+ **[anentropic.github.io/anagrapheus](https://anentropic.github.io/anagrapheus/)** has a getting-started tutorial, how-to guides for each layer, explanations of the design, and the API reference.
42
+
43
+ A worked example, a four-round EU and UK negotiation that uses every layer, lives in [`examples/negotiation/`](examples/negotiation/). From a clone, run it offline with `uv run python -m examples.negotiation`.
44
+
45
+ ## Development
46
+
47
+ See [DEVELOP.md](DEVELOP.md) for the full guide.
48
+
49
+ ### Setup
50
+
51
+ ```bash
52
+ # Install dependencies (the dev group includes the anthropic and openai extras)
53
+ uv sync --dev
54
+
55
+ # Install git hooks
56
+ prek install
57
+ ```
58
+
59
+ ### Quality gates
60
+
61
+ Before committing, run the hooks:
62
+
63
+ ```bash
64
+ prek run --all-files # file hygiene, ruff lint and format, uv lock, basedpyright, blacken-docs
65
+ ```
66
+
67
+ The hooks do not run the tests. Run each gate individually with:
68
+
69
+ ```bash
70
+ uv run basedpyright # Type checking (strict mode)
71
+ uv run ruff check # Linting
72
+ uv run ruff format --check # Formatting
73
+ uv run pytest # Tests
74
+ ```
75
+
76
+ `uv run pytest` skips the live smoke tests, which reach a real provider. Opt in with `uv run pytest -m tier3`, which needs an API key for at least one of the routes in `tests/tier3`.
77
+
78
+ ### Docs
79
+
80
+ ```bash
81
+ uv sync --dev --group docs
82
+ just docs-serve # or: just docs-build
83
+ ```
84
+
85
+ Both recipes regenerate `CHANGELOG.md` with git-cliff before building.
86
+
87
+ ## License
88
+
89
+ BSD-3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,72 @@
1
+ # anagrapheus
2
+
3
+ [![CI](https://github.com/anentropic/anagrapheus/actions/workflows/ci.yml/badge.svg)](https://github.com/anentropic/anagrapheus/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/anagrapheus)](https://pypi.org/project/anagrapheus/)
5
+ [![Python](https://img.shields.io/badge/python-3.14%2B-blue)](https://www.python.org/)
6
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue)](https://github.com/anentropic/anagrapheus/blob/main/LICENSE)
7
+
8
+ An event-sourced timeline, belief store and identity layer for long-running LLM agents. One append-only event log is the authoritative record of a run; per-agent beliefs, entity identity, a consolidated history and assembled prompts are all projections of it that can be dropped and rebuilt. Every model call made through `PinnedModel`, and every tool wrapped with `pinned_tool`, is pinned to the log, so a recorded run replays offline, exactly, without reaching the model, and a run whose prompts have changed reports where it diverged instead of quietly drawing new answers.
9
+
10
+ It is for Python developers building agents and simulations that run for many turns and hold private beliefs about each other: negotiations, games, interactive fiction. It builds on [pydantic-ai](https://ai.pydantic.dev/) for the model call and works with any provider pydantic-ai supports.
11
+
12
+ ## Installation
13
+
14
+ anagrapheus needs Python 3.14 or later.
15
+
16
+ ```bash
17
+ pip install anagrapheus # or: uv add anagrapheus
18
+ ```
19
+
20
+ The base install runs offline. To call a real provider, add its extra: `anagrapheus[anthropic]` or `anagrapheus[openai]` (the OpenAI SDK, also used for OpenAI-compatible routes such as OpenRouter).
21
+
22
+ ## Documentation
23
+
24
+ **[anentropic.github.io/anagrapheus](https://anentropic.github.io/anagrapheus/)** has a getting-started tutorial, how-to guides for each layer, explanations of the design, and the API reference.
25
+
26
+ A worked example, a four-round EU and UK negotiation that uses every layer, lives in [`examples/negotiation/`](examples/negotiation/). From a clone, run it offline with `uv run python -m examples.negotiation`.
27
+
28
+ ## Development
29
+
30
+ See [DEVELOP.md](DEVELOP.md) for the full guide.
31
+
32
+ ### Setup
33
+
34
+ ```bash
35
+ # Install dependencies (the dev group includes the anthropic and openai extras)
36
+ uv sync --dev
37
+
38
+ # Install git hooks
39
+ prek install
40
+ ```
41
+
42
+ ### Quality gates
43
+
44
+ Before committing, run the hooks:
45
+
46
+ ```bash
47
+ prek run --all-files # file hygiene, ruff lint and format, uv lock, basedpyright, blacken-docs
48
+ ```
49
+
50
+ The hooks do not run the tests. Run each gate individually with:
51
+
52
+ ```bash
53
+ uv run basedpyright # Type checking (strict mode)
54
+ uv run ruff check # Linting
55
+ uv run ruff format --check # Formatting
56
+ uv run pytest # Tests
57
+ ```
58
+
59
+ `uv run pytest` skips the live smoke tests, which reach a real provider. Opt in with `uv run pytest -m tier3`, which needs an API key for at least one of the routes in `tests/tier3`.
60
+
61
+ ### Docs
62
+
63
+ ```bash
64
+ uv sync --dev --group docs
65
+ just docs-serve # or: just docs-build
66
+ ```
67
+
68
+ Both recipes regenerate `CHANGELOG.md` with git-cliff before building.
69
+
70
+ ## License
71
+
72
+ BSD-3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "anagrapheus"
3
+ version = "0.1.1"
4
+ description = "Event-sourced timeline, belief store, and identity layer for long-running agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "anyio>=4.0",
9
+ "doxastica>=0.2",
10
+ "pydantic>=2.11",
11
+ "pydantic-ai-slim>=2",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Anentropic"
16
+ email = "ego@anentropic.com"
17
+
18
+ [project.optional-dependencies]
19
+ anthropic = ["pydantic-ai-slim[anthropic]>=2"]
20
+ openai = ["pydantic-ai-slim[openai]>=2"]
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.9.18,<1.0.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "anagrapheus[anthropic,openai]",
29
+ "basedpyright>=1.38.0",
30
+ "ipython>=9.10.0",
31
+ "pdbpp>=0.12.0.post1",
32
+ "pytest>=8.0.0",
33
+ "pytest-cov>=6.0.0",
34
+ "ruff>=0.15.1",
35
+ "hypothesis>=6.130",
36
+ ]
37
+ docs = [
38
+ "mkdocs>=1.6.0",
39
+ "mkdocs-material>=9.7.0",
40
+ "mkdocstrings[python]>=0.26.0",
41
+ "mkdocs-gen-files>=0.5.0",
42
+ "mkdocs-literate-nav>=0.6.0",
43
+ "mkdocs-section-index>=0.3.0",
44
+ ]
45
+
46
+ [tool.pyright]
47
+ pythonVersion = "3.14"
48
+ typeCheckingMode = "strict"
49
+ include = [
50
+ "src",
51
+ "tests",
52
+ ]
53
+ reportPrivateUsage = false
54
+
55
+ [tool.ruff]
56
+ target-version = "py314"
57
+ line-length = 100
58
+ src = ["src"]
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "E",
63
+ "F",
64
+ "W",
65
+ "I",
66
+ "UP",
67
+ "B",
68
+ "SIM",
69
+ "TCH",
70
+ "D",
71
+ ]
72
+ ignore = [
73
+ "D1",
74
+ "D202",
75
+ "D203",
76
+ "D212",
77
+ "D401",
78
+ "D413",
79
+ ]
80
+
81
+ [tool.ruff.lint.flake8-type-checking]
82
+ runtime-evaluated-base-classes = [
83
+ "pydantic.BaseModel",
84
+ "anagrapheus.timeline.envelope.DomainEvent",
85
+ ]
86
+
87
+ [tool.pytest.ini_options]
88
+ addopts = "-v -m 'not tier3'"
89
+ markers = ["tier3: reaches a real provider; needs a key for at least one route in tests/tier3"]
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "anagrapheus"
3
+ version = "0.1.1"
4
+ description = "Event-sourced timeline, belief store, and identity layer for long-running agents."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Anentropic", email = "ego@anentropic.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "anyio>=4.0",
12
+ "doxastica>=0.2",
13
+ "pydantic>=2.11",
14
+ "pydantic-ai-slim>=2",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.9.18,<1.0.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [project.optional-dependencies]
22
+ # Only tier 3 -- the live smoke test -- needs a real provider. Kept out of the base
23
+ # install so the library stays provider-agnostic: pydantic-ai is the seam, and which
24
+ # provider sits behind it is the consumer's choice.
25
+ anthropic = ["pydantic-ai-slim[anthropic]>=2"]
26
+ # The OpenAI SDK, which pydantic-ai uses for OpenAI itself and for every
27
+ # OpenAI-compatible route -- OpenRouter among them. Named for what it installs, as
28
+ # pydantic-ai names its own extra, rather than for one provider that uses it.
29
+ openai = ["pydantic-ai-slim[openai]>=2"]
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "anagrapheus[anthropic,openai]",
34
+ "basedpyright>=1.38.0",
35
+ "ipython>=9.10.0",
36
+ "pdbpp>=0.12.0.post1",
37
+ "pytest>=8.0.0",
38
+ "pytest-cov>=6.0.0",
39
+ "ruff>=0.15.1",
40
+ "hypothesis>=6.130",
41
+ ]
42
+ docs = [
43
+ "mkdocs>=1.6.0",
44
+ "mkdocs-material>=9.7.0",
45
+ "mkdocstrings[python]>=0.26.0",
46
+ "mkdocs-gen-files>=0.5.0",
47
+ "mkdocs-literate-nav>=0.6.0",
48
+ "mkdocs-section-index>=0.3.0",
49
+ ]
50
+
51
+ [tool.pyright]
52
+ # The minimum supported version, not the dev version: an explicit pythonVersion
53
+ # overrides the running interpreter, so setting it to the dev version lets newer
54
+ # stdlib and typing APIs type-check clean on *every* CI leg -- including the
55
+ # minimum-version leg whose job is to catch them -- and fail at runtime instead.
56
+ # Kept in step with requires-python and ruff's target-version below.
57
+ pythonVersion = "3.14"
58
+ typeCheckingMode = "strict"
59
+ include = ["src", "tests"]
60
+ reportPrivateUsage = false
61
+
62
+ [tool.ruff]
63
+ target-version = "py314"
64
+ line-length = 100
65
+ src = ["src"]
66
+
67
+ [tool.ruff.lint.flake8-type-checking]
68
+ # pydantic resolves field annotations at model build; imports its models use must
69
+ # stay runtime even when they look annotation-only.
70
+ runtime-evaluated-base-classes = [
71
+ "pydantic.BaseModel",
72
+ "anagrapheus.timeline.envelope.DomainEvent",
73
+ ]
74
+
75
+ [tool.ruff.lint]
76
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "TCH", "D"]
77
+ ignore = [
78
+ "D1", # don't require docstrings everywhere
79
+ "D202", # allow blank line after docstring (common in tests)
80
+ "D203", # conflicts with D211 (no blank line before class docstring)
81
+ "D212", # conflicts with D213 (multiline summary on second line)
82
+ "D401", # imperative mood is awkward for dunder methods
83
+ "D413", # blank line after last section not required
84
+ ]
85
+
86
+ [tool.pytest.ini_options]
87
+ addopts = "-v -m 'not tier3'"
88
+ markers = [
89
+ # Tier 3 costs money and needs a key, so it is excluded by default rather than
90
+ # skipped noisily on every local run. `-m tier3` opts in.
91
+ "tier3: reaches a real provider; needs a key for at least one route in tests/tier3",
92
+ ]
@@ -0,0 +1,45 @@
1
+ """Event-sourced timeline, belief store, identity and chronicle for long-running agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from . import assembly, beliefs, chronicle, identity, provider, timeline
9
+
10
+ if TYPE_CHECKING:
11
+ from anagrapheus.timeline.registry import EventTypeRegistry
12
+
13
+ __all__ = [
14
+ "assembly",
15
+ "beliefs",
16
+ "chronicle",
17
+ "identity",
18
+ "provider",
19
+ "register_builtin_events",
20
+ "timeline",
21
+ ]
22
+
23
+ # A library logs and never configures: without this, a consumer that has not set up
24
+ # logging would see Python's last-resort handler print our warnings to stderr. Every
25
+ # module logs under `anagrapheus.<subpackage>.<module>`, so one `getLogger("anagrapheus")`
26
+ # turns all of it up or down.
27
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
28
+
29
+
30
+ def register_builtin_events(registry: EventTypeRegistry) -> None:
31
+ """
32
+ Register every event type this library emits, across all its subpackages.
33
+
34
+ Under-registering is a live failure mode rather than an untidiness: an application
35
+ that uses the pin ledger and the identity layer over one stream, but registers only
36
+ one subpackage's events, gets an
37
+ :class:`~anagrapheus.timeline.errors.UnknownEventType` the first time a reader
38
+ reaches the other's. Library events all live under the reserved namespace and can
39
+ never collide with an application's own, so registering them all is free.
40
+
41
+ The per-subpackage functions remain for a consumer using only one of them.
42
+ """
43
+ timeline.register_builtin_events(registry)
44
+ identity.register_builtin_events(registry)
45
+ beliefs.register_builtin_events(registry)
@@ -0,0 +1,81 @@
1
+ """
2
+ Prompt assembly: what a model is shown, built only from what its role was granted.
3
+
4
+ Prompt assembly is a projection of the log, like any other read model. This package
5
+ gives it a shape:
6
+
7
+ - a **grant** of :mod:`~anagrapheus.assembly.slices`, each reading at one position and
8
+ remembering what it handed out, so a role can read nothing it was not granted and a
9
+ context always knows its sources;
10
+ - **parts** carrying a register, a rank and their sources, structured rather than
11
+ rendered (:mod:`~anagrapheus.assembly.parts`);
12
+ - :func:`~anagrapheus.assembly.assemble.assemble`, which checks citations and labels
13
+ and fits a :class:`~anagrapheus.assembly.assemble.Budget` that never sheds a binding
14
+ part.
15
+
16
+ Rendering, slot names, budgets and every schema are the application's. Pinning does
17
+ not change: the rendered messages go through
18
+ :class:`~anagrapheus.provider.PinnedModel` as before, and its content-addressed key
19
+ makes any assembly change a miss.
20
+ """
21
+
22
+ # Design: note 07; upstream agent-core doc 00 §2.
23
+
24
+ from __future__ import annotations
25
+
26
+ from anagrapheus.assembly.assemble import DEFAULT_SHED_ORDER, Budget, assemble, slices_of
27
+ from anagrapheus.assembly.errors import (
28
+ AssemblyError,
29
+ BudgetExceeded,
30
+ MislabelledPart,
31
+ NotGranted,
32
+ StaleRead,
33
+ UncitedSource,
34
+ )
35
+ from anagrapheus.assembly.parts import (
36
+ Context,
37
+ EventRef,
38
+ GivenRef,
39
+ Part,
40
+ Register,
41
+ SectionRef,
42
+ Source,
43
+ )
44
+ from anagrapheus.assembly.render import render_labelled
45
+ from anagrapheus.assembly.slices import (
46
+ BeliefSlice,
47
+ ChronicleSlice,
48
+ EventsSlice,
49
+ Given,
50
+ LogSlice,
51
+ Slice,
52
+ handed_out,
53
+ )
54
+
55
+ __all__ = [
56
+ "DEFAULT_SHED_ORDER",
57
+ "AssemblyError",
58
+ "BeliefSlice",
59
+ "Budget",
60
+ "BudgetExceeded",
61
+ "ChronicleSlice",
62
+ "Context",
63
+ "EventRef",
64
+ "EventsSlice",
65
+ "Given",
66
+ "GivenRef",
67
+ "LogSlice",
68
+ "MislabelledPart",
69
+ "NotGranted",
70
+ "Part",
71
+ "Register",
72
+ "SectionRef",
73
+ "Slice",
74
+ "Source",
75
+ "StaleRead",
76
+ "UncitedSource",
77
+ "assemble",
78
+ "handed_out",
79
+ "render_labelled",
80
+ "slices_of",
81
+ ]
@@ -0,0 +1,202 @@
1
+ """
2
+ Putting a context together: run the assembler, check what it built, fit the budget.
3
+
4
+ ``assemble`` is small on purpose. The assembler is the application's and is required
5
+ to be pure; what the library adds is the four checks that make a context trustworthy
6
+ whatever the assembler does:
7
+
8
+ - **The grant holds slices and nothing else.** A grant is a dataclass, and a field
9
+ that is not a :class:`~anagrapheus.assembly.slices.Slice` is refused, because a
10
+ store or a table in a grant is a way round every other check here.
11
+ - **The slices agree on a position.** Two slices at different positions would build a
12
+ context no single moment of the log describes.
13
+ - **A part cites only what was handed out**, and **prose is labelled as prose**: a
14
+ part citing a chronicle section must be ``generated``, and every section read must
15
+ be cited by one. That catches prose that loses its citation; it cannot stop an
16
+ assembler that cites a section *and* copies its words elsewhere, which is the limit
17
+ of checking structure rather than text.
18
+ - **A grant is read for one assembly.** Its slices must be unread when it arrives, so
19
+ the context's ``read`` is exactly this call's.
20
+ - **The budget never sheds a binding part**, and says what it shed.
21
+
22
+ **Determinism is the contract this rests on.** Same log, same position, same grant: same
23
+ parts, so the same rendered bytes, so a replay hits its pins. Nothing here can check
24
+ that an assembler is pure. The application's own tests can: build a context over a fixed
25
+ log at a fixed position, and compare
26
+ :meth:`~anagrapheus.assembly.parts.Context.describe` with a stored golden.
27
+ """
28
+
29
+ # Design: note 07 §4; upstream NVM U42 (the binding layer never sheds).
30
+
31
+ from __future__ import annotations
32
+
33
+ import dataclasses
34
+ from dataclasses import dataclass
35
+ from typing import TYPE_CHECKING
36
+
37
+ from anagrapheus.assembly.errors import (
38
+ AssemblyError,
39
+ BudgetExceeded,
40
+ MislabelledPart,
41
+ UncitedSource,
42
+ )
43
+ from anagrapheus.assembly.parts import Context, Register, SectionRef
44
+ from anagrapheus.assembly.slices import Slice, handed_out
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Callable, Iterable, Sequence
48
+
49
+ from anagrapheus.assembly.parts import Part
50
+
51
+ __all__ = ["DEFAULT_SHED_ORDER", "Budget", "assemble", "slices_of"]
52
+
53
+ #: Which registers a budget sheds first. ``binding`` is never in it.
54
+ DEFAULT_SHED_ORDER: tuple[Register, ...] = (
55
+ Register.considered,
56
+ Register.generated,
57
+ Register.craft,
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class Budget:
63
+ """
64
+ How much a context may hold, and what goes first when it holds too much.
65
+
66
+ ``count`` is the application's token counter — the library does not guess at one.
67
+ Parts are shed register by register in ``order``, lowest ``rank`` first and, among
68
+ equals, the later part first. ``binding`` may not appear in ``order``: the binding
69
+ layer never sheds, because a context missing a binding fact is wrong, not short.
70
+ """
71
+
72
+ total: int
73
+ count: Callable[[Part], int]
74
+ order: tuple[Register, ...] = DEFAULT_SHED_ORDER
75
+
76
+ def __post_init__(self) -> None:
77
+ if Register.binding in self.order:
78
+ raise ValueError("a budget may never shed binding parts")
79
+
80
+ def fit(self, parts: Sequence[Part]) -> tuple[tuple[Part, ...], tuple[Part, ...]]:
81
+ """``(kept, shed)``, with ``kept`` in assembly order."""
82
+ sizes = [self.count(p) for p in parts]
83
+ binding = sum(
84
+ n for p, n in zip(parts, sizes, strict=True) if p.register is Register.binding
85
+ )
86
+ if binding > self.total:
87
+ raise BudgetExceeded(
88
+ f"the binding parts alone come to {binding} against a total of {self.total}; "
89
+ f"a call that cannot carry its invariants is not made"
90
+ )
91
+ used = sum(sizes)
92
+ dropped: set[int] = set()
93
+ for register in self.order:
94
+ candidates = sorted(
95
+ (i for i, p in enumerate(parts) if p.register is register),
96
+ key=lambda i: (parts[i].rank, -i),
97
+ )
98
+ for i in candidates:
99
+ if used <= self.total:
100
+ break
101
+ dropped.add(i)
102
+ used -= sizes[i]
103
+ if used > self.total:
104
+ raise BudgetExceeded(
105
+ f"{used} remain against a total of {self.total} after shedding every "
106
+ f"register this budget may shed"
107
+ )
108
+ kept = tuple(p for i, p in enumerate(parts) if i not in dropped)
109
+ shed = tuple(p for i, p in enumerate(parts) if i in dropped)
110
+ return kept, shed
111
+
112
+
113
+ def slices_of(grant: object) -> tuple[Slice, ...]:
114
+ """
115
+ A grant's slices, refusing a grant that holds anything else.
116
+
117
+ Frozen, so the assembler cannot swap a field for a store after the check has run.
118
+ """
119
+ if not dataclasses.is_dataclass(grant) or isinstance(grant, type):
120
+ raise TypeError(f"a grant is a dataclass instance of slices, not {type(grant).__name__}")
121
+ params: object = getattr(type(grant), "__dataclass_params__", None)
122
+ if not getattr(params, "frozen", False):
123
+ raise TypeError(
124
+ f"grant {type(grant).__name__} is not frozen; an assembler could replace a "
125
+ f"slice with a store after the grant was checked"
126
+ )
127
+ found: list[Slice] = []
128
+ for field in dataclasses.fields(grant):
129
+ value: object = getattr(grant, field.name)
130
+ if not isinstance(value, Slice):
131
+ raise TypeError(
132
+ f"grant field {field.name!r} holds a {type(value).__name__}, which is not a "
133
+ f"slice; a grant that carries a store is a way round the firewall"
134
+ )
135
+ found.append(value)
136
+ return tuple(found)
137
+
138
+
139
+ def assemble[G](
140
+ role: str,
141
+ assembler: Callable[[G], Iterable[Part]],
142
+ grant: G,
143
+ *,
144
+ budget: Budget | None = None,
145
+ ) -> Context:
146
+ """
147
+ Run ``assembler`` over ``grant`` and return what ``role`` will be shown.
148
+
149
+ Raises :class:`~anagrapheus.assembly.errors.UncitedSource` for a part citing what
150
+ the grant never handed out, :class:`~anagrapheus.assembly.errors.MislabelledPart`
151
+ for prose presented as anything but ``generated``, and
152
+ :class:`~anagrapheus.assembly.errors.BudgetExceeded` when the binding parts alone
153
+ do not fit.
154
+ """
155
+ slices = slices_of(grant)
156
+ positions = {s.upto_seq for s in slices if s.upto_seq is not None}
157
+ if len(positions) > 1:
158
+ raise AssemblyError(
159
+ f"the slices in this grant read at different positions {sorted(positions)}; a "
160
+ f"context must describe one moment of the log"
161
+ )
162
+ if handed_out(slices):
163
+ # Ledgers live on the slices, so a reused grant would carry the last call's
164
+ # reads into this one's context, and let a part cite what this call never read.
165
+ raise AssemblyError(
166
+ "this grant was already read from; a grant is opened for one assembly, so "
167
+ "its context describes that call and no other"
168
+ )
169
+ parts = tuple(assembler(grant))
170
+ read = handed_out(slices)
171
+ known = set(read)
172
+ labelled: set[SectionRef] = set()
173
+ for part in parts:
174
+ for source in part.sources:
175
+ if source not in known:
176
+ raise UncitedSource(
177
+ f"part {part.slot!r} cites {source}, which this grant never handed out"
178
+ )
179
+ if isinstance(source, SectionRef):
180
+ if part.register is not Register.generated:
181
+ raise MislabelledPart(
182
+ f"part {part.slot!r} cites chronicle prose as {part.register.value}; "
183
+ f"model-written prose is advisory and must be labelled generated"
184
+ )
185
+ labelled.add(source)
186
+ unlabelled = [s for s in read if isinstance(s, SectionRef) and s not in labelled]
187
+ if unlabelled:
188
+ # Checking only what parts cite would let prose in under another register by
189
+ # leaving the citation off. Every section read must be accounted for by a
190
+ # generated part, which makes an omitted citation an error rather than a bypass.
191
+ raise MislabelledPart(
192
+ f"{len(unlabelled)} chronicle section(s) were read and no generated part cites "
193
+ f"them ({unlabelled[0].section_id!r} first); read prose only to show it as prose"
194
+ )
195
+ kept, shed = (parts, ()) if budget is None else budget.fit(parts)
196
+ return Context(
197
+ role=role,
198
+ upto_seq=next(iter(positions), None),
199
+ parts=kept,
200
+ shed=shed,
201
+ read=read,
202
+ )