nomark-engine 0.1.0__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.
- nomark_engine-0.1.0/.gitignore +8 -0
- nomark_engine-0.1.0/PKG-INFO +67 -0
- nomark_engine-0.1.0/README.md +45 -0
- nomark_engine-0.1.0/pyproject.toml +43 -0
- nomark_engine-0.1.0/src/nomark_engine/__init__.py +51 -0
- nomark_engine-0.1.0/src/nomark_engine/classifier.py +134 -0
- nomark_engine-0.1.0/src/nomark_engine/decay.py +46 -0
- nomark_engine-0.1.0/src/nomark_engine/ledger.py +91 -0
- nomark_engine-0.1.0/src/nomark_engine/resolver.py +304 -0
- nomark_engine-0.1.0/src/nomark_engine/schema.py +189 -0
- nomark_engine-0.1.0/src/nomark_engine/utility.py +122 -0
- nomark_engine-0.1.0/tests/__init__.py +0 -0
- nomark_engine-0.1.0/tests/test_classifier.py +80 -0
- nomark_engine-0.1.0/tests/test_decay.py +69 -0
- nomark_engine-0.1.0/tests/test_ledger.py +167 -0
- nomark_engine-0.1.0/tests/test_resolver.py +249 -0
- nomark_engine-0.1.0/tests/test_schema.py +132 -0
- nomark_engine-0.1.0/tests/test_utility.py +137 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nomark-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NOMARK Engine — open-core agent outcome quality resolver
|
|
5
|
+
Project-URL: Homepage, https://github.com/nomark-dev/nomark
|
|
6
|
+
Project-URL: Repository, https://github.com/nomark-dev/nomark
|
|
7
|
+
Author: Reece Frazier
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agent,ai,intent,nomark,preferences,quality,trust
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# nomark-engine
|
|
24
|
+
|
|
25
|
+
Open-core agent outcome quality resolver. Understands what a human means from incomplete input by learning preferences across sessions and platforms.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nomark-engine
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from nomark_engine import create_resolver, parse_ledger, ResolverConfig
|
|
37
|
+
|
|
38
|
+
# Parse a NOMARK ledger
|
|
39
|
+
entries = parse_ledger(open("nomark-ledger.jsonl").read())
|
|
40
|
+
|
|
41
|
+
# Create resolver
|
|
42
|
+
resolver = create_resolver(ResolverConfig(entries=entries))
|
|
43
|
+
|
|
44
|
+
# Resolve all preference dimensions
|
|
45
|
+
result = resolver.resolve_all()
|
|
46
|
+
for dim, res in result.dimensions.items():
|
|
47
|
+
if res.winner:
|
|
48
|
+
print(f"{dim}: {res.winner.pref.target} (score: {res.winner.score})")
|
|
49
|
+
|
|
50
|
+
# Resolve intent from natural language
|
|
51
|
+
result = resolver.resolve_input("make it shorter")
|
|
52
|
+
for match in result.meaning_maps:
|
|
53
|
+
print(f"Matched: {match.trigger} -> {match.intent}")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Modules
|
|
57
|
+
|
|
58
|
+
- **Schema** — Pydantic v2 models for all signal types (pref, map, asn, meta, rub)
|
|
59
|
+
- **Classifier** — Input tier classification (pass-through, routing, extraction)
|
|
60
|
+
- **Resolver** — MEE weighted scoring with scope matching and instability detection
|
|
61
|
+
- **Ledger** — JSONL parser/writer with capacity constraints
|
|
62
|
+
- **Decay** — Time-based decay with contradiction acceleration
|
|
63
|
+
- **Utility** — Multi-factor utility scoring and capacity-bounded pruning
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
Apache 2.0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# nomark-engine
|
|
2
|
+
|
|
3
|
+
Open-core agent outcome quality resolver. Understands what a human means from incomplete input by learning preferences across sessions and platforms.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nomark-engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from nomark_engine import create_resolver, parse_ledger, ResolverConfig
|
|
15
|
+
|
|
16
|
+
# Parse a NOMARK ledger
|
|
17
|
+
entries = parse_ledger(open("nomark-ledger.jsonl").read())
|
|
18
|
+
|
|
19
|
+
# Create resolver
|
|
20
|
+
resolver = create_resolver(ResolverConfig(entries=entries))
|
|
21
|
+
|
|
22
|
+
# Resolve all preference dimensions
|
|
23
|
+
result = resolver.resolve_all()
|
|
24
|
+
for dim, res in result.dimensions.items():
|
|
25
|
+
if res.winner:
|
|
26
|
+
print(f"{dim}: {res.winner.pref.target} (score: {res.winner.score})")
|
|
27
|
+
|
|
28
|
+
# Resolve intent from natural language
|
|
29
|
+
result = resolver.resolve_input("make it shorter")
|
|
30
|
+
for match in result.meaning_maps:
|
|
31
|
+
print(f"Matched: {match.trigger} -> {match.intent}")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Modules
|
|
35
|
+
|
|
36
|
+
- **Schema** — Pydantic v2 models for all signal types (pref, map, asn, meta, rub)
|
|
37
|
+
- **Classifier** — Input tier classification (pass-through, routing, extraction)
|
|
38
|
+
- **Resolver** — MEE weighted scoring with scope matching and instability detection
|
|
39
|
+
- **Ledger** — JSONL parser/writer with capacity constraints
|
|
40
|
+
- **Decay** — Time-based decay with contradiction acceleration
|
|
41
|
+
- **Utility** — Multi-factor utility scoring and capacity-bounded pruning
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
Apache 2.0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nomark-engine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "NOMARK Engine — open-core agent outcome quality resolver"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Reece Frazier" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["nomark", "ai", "agent", "preferences", "intent", "quality", "trust"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pydantic>=2.0,<3",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/nomark-dev/nomark"
|
|
33
|
+
Repository = "https://github.com/nomark-dev/nomark"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/nomark_engine"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.pyright]
|
|
42
|
+
include = ["src"]
|
|
43
|
+
pythonVersion = "3.10"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""NOMARK Engine — open-core agent outcome quality resolver."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .schema import (
|
|
6
|
+
Context, Outcome, RequestType, PatternType, RubricStage, SignalType, Scope,
|
|
7
|
+
ContextCounts, OutcomeCounts,
|
|
8
|
+
SigPref, SigMap, SigAsn, SigMeta, SigRub,
|
|
9
|
+
LedgerEntryMeta, LedgerEntryPref, LedgerEntryMap, LedgerEntryAsn, LedgerEntryRub,
|
|
10
|
+
LedgerEntry,
|
|
11
|
+
parse_ledger_entry,
|
|
12
|
+
)
|
|
13
|
+
from .decay import compute_decay, effective_weight
|
|
14
|
+
from .ledger import (
|
|
15
|
+
parse_ledger, write_ledger, parse_ledger_line, format_ledger_line,
|
|
16
|
+
count_by_type, check_capacity, estimate_tokens,
|
|
17
|
+
ENTRY_CAPS, TOTAL_CAP,
|
|
18
|
+
)
|
|
19
|
+
from .utility import utility_score, is_protected, prune_to_capacity
|
|
20
|
+
from .classifier import classify, ClassificationResult, InputTier
|
|
21
|
+
from .resolver import (
|
|
22
|
+
scope_specificity, scope_matches, resolver_score,
|
|
23
|
+
resolve_dimension, match_meaning_maps, find_defaults,
|
|
24
|
+
create_resolver, Resolver, ResolverConfig, ResolverResult,
|
|
25
|
+
DimensionResult, MeaningMapMatch, DefaultMatch, ScoredPref, ScoringFactors, ResolverMeta,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Schema types
|
|
30
|
+
"Context", "Outcome", "RequestType", "PatternType", "RubricStage", "SignalType", "Scope",
|
|
31
|
+
"ContextCounts", "OutcomeCounts",
|
|
32
|
+
"SigPref", "SigMap", "SigAsn", "SigMeta", "SigRub",
|
|
33
|
+
"LedgerEntryMeta", "LedgerEntryPref", "LedgerEntryMap", "LedgerEntryAsn", "LedgerEntryRub",
|
|
34
|
+
"LedgerEntry",
|
|
35
|
+
"parse_ledger_entry",
|
|
36
|
+
# Decay
|
|
37
|
+
"compute_decay", "effective_weight",
|
|
38
|
+
# Ledger
|
|
39
|
+
"parse_ledger", "write_ledger", "parse_ledger_line", "format_ledger_line",
|
|
40
|
+
"count_by_type", "check_capacity", "estimate_tokens",
|
|
41
|
+
"ENTRY_CAPS", "TOTAL_CAP",
|
|
42
|
+
# Utility
|
|
43
|
+
"utility_score", "is_protected", "prune_to_capacity",
|
|
44
|
+
# Classifier
|
|
45
|
+
"classify", "ClassificationResult", "InputTier",
|
|
46
|
+
# Resolver
|
|
47
|
+
"scope_specificity", "scope_matches", "resolver_score",
|
|
48
|
+
"resolve_dimension", "match_meaning_maps", "find_defaults",
|
|
49
|
+
"create_resolver", "Resolver", "ResolverConfig", "ResolverResult",
|
|
50
|
+
"DimensionResult", "MeaningMapMatch", "DefaultMatch", "ScoredPref", "ScoringFactors", "ResolverMeta",
|
|
51
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Input classification (MEE Spec Section 1.1).
|
|
2
|
+
|
|
3
|
+
Ports packages/engine/src/classifier.ts.
|
|
4
|
+
|
|
5
|
+
Tier 0: Pass-through — already resolved. Confirmations, selections, JSON, exit codes.
|
|
6
|
+
Tier 1: Routing — match to established pattern. Skill invocations, continuations, corrections.
|
|
7
|
+
Tier 2: Extraction — full intent reconstruction through resolver + gate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, Protocol
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
InputTier = Literal[0, 1, 2]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ClassificationResult:
|
|
23
|
+
tier: InputTier
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClassifierRule(Protocol):
|
|
28
|
+
tier: InputTier
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
def test(self, input: str) -> bool: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _Rule:
|
|
36
|
+
tier: InputTier
|
|
37
|
+
reason: str
|
|
38
|
+
_test: object # callable
|
|
39
|
+
|
|
40
|
+
def test(self, input: str) -> bool:
|
|
41
|
+
return self._test(input) # type: ignore[operator]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_confirmation(s: str) -> bool:
|
|
45
|
+
return bool(re.match(
|
|
46
|
+
r"^(y|yes|no|n|ok|done|skip|cancel|approve|reject|confirm|confirmed)$",
|
|
47
|
+
s.strip(), re.IGNORECASE,
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_numeric(s: str) -> bool:
|
|
52
|
+
return bool(re.match(r"^[0-9]+$", s.strip()))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_json(s: str) -> bool:
|
|
56
|
+
t = s.strip()
|
|
57
|
+
if not (t.startswith("{") or t.startswith("[")):
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
json.loads(s)
|
|
61
|
+
return True
|
|
62
|
+
except (json.JSONDecodeError, ValueError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_exit(s: str) -> bool:
|
|
67
|
+
return bool(re.match(r"^(exit|quit|bye|stop)\s*$", s.strip(), re.IGNORECASE))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_hash(s: str) -> bool:
|
|
71
|
+
return bool(re.match(r"^[a-f0-9]{6,40}$", s.strip(), re.IGNORECASE))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_skill(s: str) -> bool:
|
|
75
|
+
return bool(re.match(r"^/\w", s.strip()))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_continuation(s: str) -> bool:
|
|
79
|
+
return bool(re.match(
|
|
80
|
+
r"^(continue|go ahead|proceed|next|keep going|resume)\s*$",
|
|
81
|
+
s.strip(), re.IGNORECASE,
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_correction(s: str) -> bool:
|
|
86
|
+
t = s.strip()
|
|
87
|
+
return bool(
|
|
88
|
+
re.match(r"^(no[,.]?\s+(not that|wrong|different|the other|I meant))", t, re.IGNORECASE)
|
|
89
|
+
or re.match(r"^(actually|wait|hold on|scratch that|never\s?mind)", t, re.IGNORECASE)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_letter_selection(s: str) -> bool:
|
|
94
|
+
t = s.strip()
|
|
95
|
+
return 0 < len(t) <= 3 and bool(re.match(r"^[a-z]$", t, re.IGNORECASE))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_TIER_0_RULES: list[_Rule] = [
|
|
99
|
+
_Rule(0, "confirmation", _is_confirmation),
|
|
100
|
+
_Rule(0, "numeric_selection", _is_numeric),
|
|
101
|
+
_Rule(0, "json_data", _is_json),
|
|
102
|
+
_Rule(0, "exit_signal", _is_exit),
|
|
103
|
+
_Rule(0, "hash_or_id", _is_hash),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
_TIER_1_RULES: list[_Rule] = [
|
|
107
|
+
_Rule(1, "skill_invocation", _is_skill),
|
|
108
|
+
_Rule(1, "continuation", _is_continuation),
|
|
109
|
+
_Rule(1, "correction", _is_correction),
|
|
110
|
+
_Rule(1, "letter_selection", _is_letter_selection),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def classify(input: str, custom_rules: list[_Rule] | None = None) -> ClassificationResult:
|
|
115
|
+
"""Classify input into Tier 0 (pass-through), Tier 1 (routing), or Tier 2 (extraction)."""
|
|
116
|
+
trimmed = input.strip()
|
|
117
|
+
|
|
118
|
+
if not trimmed:
|
|
119
|
+
return ClassificationResult(tier=0, reason="empty_input")
|
|
120
|
+
|
|
121
|
+
if custom_rules:
|
|
122
|
+
for rule in custom_rules:
|
|
123
|
+
if rule.test(trimmed):
|
|
124
|
+
return ClassificationResult(tier=rule.tier, reason=rule.reason)
|
|
125
|
+
|
|
126
|
+
for rule in _TIER_0_RULES:
|
|
127
|
+
if rule.test(trimmed):
|
|
128
|
+
return ClassificationResult(tier=rule.tier, reason=rule.reason)
|
|
129
|
+
|
|
130
|
+
for rule in _TIER_1_RULES:
|
|
131
|
+
if rule.test(trimmed):
|
|
132
|
+
return ClassificationResult(tier=rule.tier, reason=rule.reason)
|
|
133
|
+
|
|
134
|
+
return ClassificationResult(tier=2, reason="substantive_input")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Continuous decay computation (MEE Spec Section 8).
|
|
2
|
+
|
|
3
|
+
Ports packages/engine/src/decay.ts.
|
|
4
|
+
|
|
5
|
+
Base: max(0.1, 0.98^(days/30))
|
|
6
|
+
Contradiction acceleration: decay * 0.85 when recent_contradictions >= 2
|
|
7
|
+
Reinforcement recovery: decay * 1.1 (capped 1.0) when reinforced within 7 days
|
|
8
|
+
Floor: 0.1 — never total erasure
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute_decay(
|
|
17
|
+
last_date: str,
|
|
18
|
+
_contradictions: int,
|
|
19
|
+
recent_contradictions: int,
|
|
20
|
+
recent_reinforcement: bool,
|
|
21
|
+
now: datetime | None = None,
|
|
22
|
+
) -> float:
|
|
23
|
+
if now is None:
|
|
24
|
+
now = datetime.now(timezone.utc)
|
|
25
|
+
|
|
26
|
+
last = datetime.fromisoformat(last_date)
|
|
27
|
+
if last.tzinfo is None:
|
|
28
|
+
last = last.replace(tzinfo=timezone.utc)
|
|
29
|
+
if now.tzinfo is None:
|
|
30
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
31
|
+
|
|
32
|
+
days_since_last = max(0.0, (now - last).total_seconds() / 86400)
|
|
33
|
+
|
|
34
|
+
decay = max(0.1, 0.98 ** (days_since_last / 30))
|
|
35
|
+
|
|
36
|
+
if recent_contradictions >= 2:
|
|
37
|
+
decay = max(0.1, decay * 0.85)
|
|
38
|
+
|
|
39
|
+
if recent_reinforcement:
|
|
40
|
+
decay = min(1.0, decay * 1.1)
|
|
41
|
+
|
|
42
|
+
return round(decay * 1000) / 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def effective_weight(w: float, decay: float) -> float:
|
|
46
|
+
return round(w * decay * 1000) / 1000
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Ledger JSONL parser/writer with capacity constraints.
|
|
2
|
+
|
|
3
|
+
Ports packages/engine/src/ledger.ts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .schema import LedgerEntry, SignalType, parse_ledger_entry
|
|
12
|
+
|
|
13
|
+
ENTRY_CAPS: dict[SignalType, int] = {
|
|
14
|
+
"meta": 1,
|
|
15
|
+
"pref": 20,
|
|
16
|
+
"map": 10,
|
|
17
|
+
"asn": 5,
|
|
18
|
+
"rub": 4,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
TOTAL_CAP = 40
|
|
22
|
+
|
|
23
|
+
_SIGNAL_PREFIX_RE = re.compile(r"^\[sig:(\w+)\]\s+(.+)$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_ledger_line(line: str) -> LedgerEntry | None:
|
|
27
|
+
"""Parse a single ledger line: `[sig:type] {json}`. Returns None for empty or unparseable lines."""
|
|
28
|
+
trimmed = line.strip()
|
|
29
|
+
if not trimmed:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
match = _SIGNAL_PREFIX_RE.match(trimmed)
|
|
33
|
+
if not match:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
signal_type = match.group(1)
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(match.group(2))
|
|
39
|
+
except (json.JSONDecodeError, ValueError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
return parse_ledger_entry(signal_type, data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_ledger_line(entry: LedgerEntry) -> str:
|
|
46
|
+
"""Format a ledger entry back to `[sig:type] {json}` string."""
|
|
47
|
+
return f"[sig:{entry.type}] {json.dumps(entry.data.model_dump(exclude_none=True))}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_ledger(content: str) -> list[LedgerEntry]:
|
|
51
|
+
"""Parse a full ledger JSONL string into typed entries."""
|
|
52
|
+
results: list[LedgerEntry] = []
|
|
53
|
+
for line in content.split("\n"):
|
|
54
|
+
entry = parse_ledger_line(line)
|
|
55
|
+
if entry is not None:
|
|
56
|
+
results.append(entry)
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_ledger(entries: list[LedgerEntry]) -> str:
|
|
61
|
+
"""Serialize ledger entries to JSONL string with typed prefixes."""
|
|
62
|
+
return "\n".join(format_ledger_line(e) for e in entries) + "\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def count_by_type(entries: list[LedgerEntry]) -> dict[SignalType, int]:
|
|
66
|
+
"""Count entries by type."""
|
|
67
|
+
counts: dict[SignalType, int] = {"meta": 0, "pref": 0, "map": 0, "asn": 0, "rub": 0}
|
|
68
|
+
for entry in entries:
|
|
69
|
+
counts[entry.type] += 1
|
|
70
|
+
return counts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_capacity(entries: list[LedgerEntry]) -> list[str]:
|
|
74
|
+
"""Check if ledger exceeds capacity constraints. Returns violations or empty list."""
|
|
75
|
+
violations: list[str] = []
|
|
76
|
+
counts = count_by_type(entries)
|
|
77
|
+
|
|
78
|
+
if len(entries) > TOTAL_CAP:
|
|
79
|
+
violations.append(f"total {len(entries)} exceeds cap {TOTAL_CAP}")
|
|
80
|
+
|
|
81
|
+
for signal_type, cap in ENTRY_CAPS.items():
|
|
82
|
+
count = counts.get(signal_type, 0)
|
|
83
|
+
if count > cap:
|
|
84
|
+
violations.append(f"{signal_type} count {count} exceeds cap {cap}")
|
|
85
|
+
|
|
86
|
+
return violations
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def estimate_tokens(entries: list[LedgerEntry]) -> int:
|
|
90
|
+
"""Estimate token count for ledger entries (~75 tokens per entry)."""
|
|
91
|
+
return len(entries) * 75
|