nomark-engine 0.1.0__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.
@@ -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")
nomark_engine/decay.py ADDED
@@ -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
@@ -0,0 +1,304 @@
1
+ """MEE Resolver — intent resolution from preference ledger.
2
+
3
+ Ports packages/engine/src/resolver.ts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+
13
+ from .schema import LedgerEntry, SigPref, SigMap, SigAsn, LedgerEntryPref, LedgerEntryMap, LedgerEntryAsn
14
+ from .decay import effective_weight
15
+ from .ledger import parse_ledger
16
+
17
+
18
+ def scope_specificity(scope: str) -> float:
19
+ if scope == "*":
20
+ return 0.3
21
+ if "+" in scope:
22
+ return 1.0
23
+ return 0.7
24
+
25
+
26
+ def scope_matches(scope: str, context: str | None = None, topic: str | None = None) -> bool:
27
+ if scope == "*":
28
+ return True
29
+
30
+ for part in scope.split("+"):
31
+ kv = part.split(":", 1)
32
+ if len(kv) != 2:
33
+ continue
34
+ key, value = kv
35
+ if key == "context" and context and value != context:
36
+ return False
37
+ if key == "topic" and topic and value != topic:
38
+ return False
39
+
40
+ return True
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ScoringFactors:
45
+ specificity: float
46
+ evidence: float
47
+ recency: float
48
+ stability: float
49
+ portability: float
50
+ contradiction_penalty: float
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ScoredPref:
55
+ pref: SigPref
56
+ score: float
57
+ effective_w: float
58
+ factors: ScoringFactors
59
+
60
+
61
+ def resolver_score(pref: SigPref, now: datetime | None = None) -> ScoredPref:
62
+ """Score a preference entry using the five-factor weighted formula."""
63
+ if now is None:
64
+ now = datetime.now(timezone.utc)
65
+
66
+ last = datetime.fromisoformat(pref.last)
67
+ if last.tzinfo is None:
68
+ last = last.replace(tzinfo=timezone.utc)
69
+ if now.tzinfo is None:
70
+ now = now.replace(tzinfo=timezone.utc)
71
+
72
+ days_since_last = max(0.0, (now - last).total_seconds() / 86400)
73
+
74
+ specificity = scope_specificity(pref.scope)
75
+ evidence = min(1.0, pref.n / 20)
76
+ recency = max(0.0, 1.0 - days_since_last / 180)
77
+ stability = (1.0 - (pref.ctd / pref.n)) if pref.n > 0 else 0.5
78
+
79
+ portability = 0.0
80
+ if pref.src:
81
+ src_dict = pref.src.model_dump(exclude_none=True)
82
+ portability = sum(1 for v in src_dict.values() if isinstance(v, (int, float)) and v > 0) / 3
83
+
84
+ contradiction_penalty = pref.ctd * 0.15
85
+
86
+ score = (
87
+ (specificity * 0.30)
88
+ + (evidence * 0.25)
89
+ + (recency * 0.20)
90
+ + (stability * 0.15)
91
+ + (portability * 0.10)
92
+ - contradiction_penalty
93
+ )
94
+
95
+ return ScoredPref(
96
+ pref=pref,
97
+ score=round(score * 1000) / 1000,
98
+ effective_w=effective_weight(pref.w, pref.decay),
99
+ factors=ScoringFactors(
100
+ specificity=specificity,
101
+ evidence=evidence,
102
+ recency=recency,
103
+ stability=stability,
104
+ portability=portability,
105
+ contradiction_penalty=contradiction_penalty,
106
+ ),
107
+ )
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class DimensionResult:
112
+ dimension: str
113
+ winner: ScoredPref | None
114
+ runner_up: ScoredPref | None
115
+ unstable: bool
116
+ action: str # 'use_winner' | 'ask'
117
+ candidates: int
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class MeaningMapMatch:
122
+ trigger: str
123
+ intent: list[str]
124
+ confidence: float
125
+ scope: str
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class DefaultMatch:
130
+ field: str
131
+ default: str
132
+ accuracy: float
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class ResolverMeta:
137
+ entry_count: int
138
+ estimated_tokens: int
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class ResolverResult:
143
+ dimensions: dict[str, DimensionResult]
144
+ meaning_maps: list[MeaningMapMatch]
145
+ defaults: list[DefaultMatch]
146
+ meta: ResolverMeta
147
+
148
+
149
+ def resolve_dimension(
150
+ entries: list[LedgerEntry],
151
+ dimension: str,
152
+ context: str | None = None,
153
+ topic: str | None = None,
154
+ now: datetime | None = None,
155
+ ) -> DimensionResult:
156
+ if now is None:
157
+ now = datetime.now(timezone.utc)
158
+
159
+ candidates = [
160
+ resolver_score(e.data, now)
161
+ for e in entries
162
+ if e.type == "pref" and e.data.dim == dimension and scope_matches(e.data.scope, context, topic)
163
+ ]
164
+ candidates.sort(key=lambda c: c.score, reverse=True)
165
+
166
+ if not candidates:
167
+ return DimensionResult(
168
+ dimension=dimension, winner=None, runner_up=None,
169
+ unstable=False, action="ask", candidates=0,
170
+ )
171
+
172
+ winner = candidates[0]
173
+ runner_up = candidates[1] if len(candidates) > 1 else None
174
+ unstable = winner.score < 0.4
175
+
176
+ return DimensionResult(
177
+ dimension=dimension, winner=winner, runner_up=runner_up,
178
+ unstable=unstable, action="ask" if unstable else "use_winner",
179
+ candidates=len(candidates),
180
+ )
181
+
182
+
183
+ def match_meaning_maps(
184
+ entries: list[LedgerEntry],
185
+ input_text: str,
186
+ context: str | None = None,
187
+ topic: str | None = None,
188
+ ) -> list[MeaningMapMatch]:
189
+ normalized = input_text.lower().strip()
190
+
191
+ results: list[MeaningMapMatch] = []
192
+ for e in entries:
193
+ if e.type != "map":
194
+ continue
195
+ data: SigMap = e.data
196
+ entry_scope = data.scope or "*"
197
+ if normalized.find(data.trigger.lower()) == -1:
198
+ continue
199
+ if not scope_matches(entry_scope, context, topic):
200
+ continue
201
+ results.append(MeaningMapMatch(
202
+ trigger=data.trigger,
203
+ intent=data.intent,
204
+ confidence=data.conf,
205
+ scope=entry_scope,
206
+ ))
207
+
208
+ return results
209
+
210
+
211
+ def find_defaults(entries: list[LedgerEntry]) -> list[DefaultMatch]:
212
+ return [
213
+ DefaultMatch(field=e.data.field, default=e.data.default, accuracy=e.data.accuracy)
214
+ for e in entries
215
+ if e.type == "asn"
216
+ ]
217
+
218
+
219
+ _SIGNAL_PREFIX_RE = re.compile(r"^\[sig:(\w+)\]\s+(.+)$")
220
+
221
+
222
+ def _parse_ledger_content(content: str) -> list[LedgerEntry]:
223
+ return parse_ledger(content)
224
+
225
+
226
+ @dataclass
227
+ class ResolverConfig:
228
+ ledger_path: str | None = None
229
+ ledger_content: str | None = None
230
+ entries: list[LedgerEntry] | None = None
231
+ context: str | None = None
232
+ topic: str | None = None
233
+ now: datetime | None = None
234
+
235
+
236
+ def _load_entries(config: ResolverConfig) -> list[LedgerEntry]:
237
+ if config.entries is not None:
238
+ return config.entries
239
+ if config.ledger_content is not None:
240
+ return _parse_ledger_content(config.ledger_content)
241
+ return []
242
+
243
+
244
+ class Resolver:
245
+ """Resolver instance created from configuration."""
246
+
247
+ def __init__(self, config: ResolverConfig) -> None:
248
+ self._entries = _load_entries(config)
249
+ self._now = config.now or datetime.now(timezone.utc)
250
+ self._context = config.context
251
+ self._topic = config.topic
252
+
253
+ def resolve(self, dimension: str) -> DimensionResult:
254
+ return resolve_dimension(self._entries, dimension, self._context, self._topic, self._now)
255
+
256
+ def resolve_all(self) -> ResolverResult:
257
+ dimensions: set[str] = set()
258
+ for entry in self._entries:
259
+ if entry.type == "pref":
260
+ dimensions.add(entry.data.dim)
261
+
262
+ results: dict[str, DimensionResult] = {}
263
+ for dim in dimensions:
264
+ results[dim] = resolve_dimension(self._entries, dim, self._context, self._topic, self._now)
265
+
266
+ return ResolverResult(
267
+ dimensions=results,
268
+ meaning_maps=[],
269
+ defaults=find_defaults(self._entries),
270
+ meta=ResolverMeta(
271
+ entry_count=len(self._entries),
272
+ estimated_tokens=len(self._entries) * 75,
273
+ ),
274
+ )
275
+
276
+ def resolve_input(self, input_text: str) -> ResolverResult:
277
+ dimensions: set[str] = set()
278
+ for entry in self._entries:
279
+ if entry.type == "pref":
280
+ dimensions.add(entry.data.dim)
281
+
282
+ dim_results: dict[str, DimensionResult] = {}
283
+ for dim in dimensions:
284
+ dim_results[dim] = resolve_dimension(self._entries, dim, self._context, self._topic, self._now)
285
+
286
+ return ResolverResult(
287
+ dimensions=dim_results,
288
+ meaning_maps=match_meaning_maps(self._entries, input_text, self._context, self._topic),
289
+ defaults=find_defaults(self._entries),
290
+ meta=ResolverMeta(
291
+ entry_count=len(self._entries),
292
+ estimated_tokens=len(self._entries) * 75,
293
+ ),
294
+ )
295
+
296
+ def get_entries(self) -> list[LedgerEntry]:
297
+ return list(self._entries)
298
+
299
+
300
+ def create_resolver(config: ResolverConfig | None = None, **kwargs) -> Resolver:
301
+ """Create a resolver instance from configuration."""
302
+ if config is None:
303
+ config = ResolverConfig(**kwargs)
304
+ return Resolver(config)
@@ -0,0 +1,189 @@
1
+ """Schema types and validation for NOMARK ledger entries.
2
+
3
+ Ports packages/engine/src/schema.ts — Pydantic v2 models instead of Zod.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Literal, Union
10
+
11
+ from pydantic import BaseModel, Field, field_validator
12
+
13
+ # --- String literal unions (match TS types) ---
14
+
15
+ Context = Literal["chat", "cowork", "code"]
16
+ Outcome = Literal["accepted", "edited", "corrected", "rejected", "abandoned", "unknown"]
17
+ RequestType = Literal["question", "task", "brainstorm", "decision", "critique", "creative", "continuation", "reaction"]
18
+ PatternType = Literal["rewrite_request", "scope_change", "quality_complaint", "format_request", "style_override", "abort"]
19
+ RubricStage = Literal["ephemeral", "pending", "proven", "trusted"]
20
+ SignalType = Literal["meta", "pref", "map", "asn", "rub"]
21
+
22
+ # Scope is a free-form string: '*' | 'context:{ctx}' | 'topic:{topic}' | compound
23
+ Scope = str
24
+
25
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
26
+ _ISO_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
27
+
28
+
29
+ def _validate_iso_date(v: str) -> str:
30
+ if not _ISO_DATE_RE.match(v):
31
+ raise ValueError(f"Expected ISO date (YYYY-MM-DD), got {v!r}")
32
+ return v
33
+
34
+
35
+ # --- Context counts ---
36
+
37
+ class ContextCounts(BaseModel):
38
+ chat: int | None = None
39
+ code: int | None = None
40
+ cowork: int | None = None
41
+
42
+
43
+ # --- Signal data models ---
44
+
45
+ class SigPref(BaseModel):
46
+ dim: str
47
+ target: str
48
+ w: float = Field(ge=0, le=1)
49
+ n: int = Field(ge=0)
50
+ src: ContextCounts
51
+ ctd: int = Field(ge=0)
52
+ scope: str
53
+ decay: float = Field(ge=0, le=1)
54
+ last: str
55
+ staged: bool | None = None
56
+ note: str | None = None
57
+
58
+ @field_validator("last")
59
+ @classmethod
60
+ def _check_last(cls, v: str) -> str:
61
+ return _validate_iso_date(v)
62
+
63
+
64
+ class SigMap(BaseModel):
65
+ trigger: str
66
+ pattern_type: PatternType
67
+ intent: list[str]
68
+ neg: list[str] | None = None
69
+ conf: float = Field(ge=0, le=1)
70
+ n: int = Field(ge=0)
71
+ scope: str | None = None
72
+ last: str
73
+
74
+ @field_validator("last")
75
+ @classmethod
76
+ def _check_last(cls, v: str) -> str:
77
+ return _validate_iso_date(v)
78
+
79
+
80
+ class SigAsn(BaseModel):
81
+ field: str
82
+ default: str
83
+ accuracy: float = Field(ge=0, le=1)
84
+ total: int = Field(ge=0)
85
+ correct: int = Field(ge=0)
86
+ last: str
87
+
88
+ @field_validator("last")
89
+ @classmethod
90
+ def _check_last(cls, v: str) -> str:
91
+ return _validate_iso_date(v)
92
+
93
+
94
+ class OutcomeCounts(BaseModel):
95
+ accepted: int | None = None
96
+ edited: int | None = None
97
+ corrected: int | None = None
98
+ rejected: int | None = None
99
+ abandoned: int | None = None
100
+
101
+
102
+ class SigMeta(BaseModel):
103
+ profile: dict[str, object]
104
+ signals: int = Field(ge=0)
105
+ by_ctx: ContextCounts
106
+ by_out: OutcomeCounts
107
+ avg_conf: float = Field(ge=0, le=1)
108
+ avg_q: float = Field(ge=0)
109
+ updated: str
110
+
111
+ @field_validator("updated")
112
+ @classmethod
113
+ def _check_updated(cls, v: str) -> str:
114
+ return _validate_iso_date(v)
115
+
116
+
117
+ class SigRub(BaseModel):
118
+ id: str
119
+ fmt: str
120
+ stage: RubricStage
121
+ uses: int = Field(ge=0)
122
+ accepts: int = Field(ge=0)
123
+ avg_ed: float = Field(ge=0)
124
+ dims: dict[str, float]
125
+ min: float
126
+ ref: str | None = None
127
+ last: str
128
+
129
+ @field_validator("last")
130
+ @classmethod
131
+ def _check_last(cls, v: str) -> str:
132
+ return _validate_iso_date(v)
133
+
134
+
135
+ # --- Ledger entry (discriminated union) ---
136
+
137
+ class LedgerEntryMeta(BaseModel):
138
+ type: Literal["meta"] = "meta"
139
+ data: SigMeta
140
+
141
+
142
+ class LedgerEntryPref(BaseModel):
143
+ type: Literal["pref"] = "pref"
144
+ data: SigPref
145
+
146
+
147
+ class LedgerEntryMap(BaseModel):
148
+ type: Literal["map"] = "map"
149
+ data: SigMap
150
+
151
+
152
+ class LedgerEntryAsn(BaseModel):
153
+ type: Literal["asn"] = "asn"
154
+ data: SigAsn
155
+
156
+
157
+ class LedgerEntryRub(BaseModel):
158
+ type: Literal["rub"] = "rub"
159
+ data: SigRub
160
+
161
+
162
+ LedgerEntry = Union[LedgerEntryMeta, LedgerEntryPref, LedgerEntryMap, LedgerEntryAsn, LedgerEntryRub]
163
+
164
+ _SIGNAL_TYPE_MAP: dict[str, type[BaseModel]] = {
165
+ "meta": LedgerEntryMeta,
166
+ "pref": LedgerEntryPref,
167
+ "map": LedgerEntryMap,
168
+ "asn": LedgerEntryAsn,
169
+ "rub": LedgerEntryRub,
170
+ }
171
+
172
+ _SIGNAL_DATA_MAP: dict[str, type[BaseModel]] = {
173
+ "meta": SigMeta,
174
+ "pref": SigPref,
175
+ "map": SigMap,
176
+ "asn": SigAsn,
177
+ "rub": SigRub,
178
+ }
179
+
180
+
181
+ def parse_ledger_entry(signal_type: str, data: dict) -> LedgerEntry | None:
182
+ """Parse a signal type + data dict into a typed LedgerEntry. Returns None on validation failure."""
183
+ entry_cls = _SIGNAL_TYPE_MAP.get(signal_type)
184
+ if entry_cls is None:
185
+ return None
186
+ try:
187
+ return entry_cls.model_validate({"type": signal_type, "data": data}) # type: ignore[return-value]
188
+ except Exception:
189
+ return None
@@ -0,0 +1,122 @@
1
+ """Utility scoring and capacity-bounded pruning (MEE Spec Section 7.3).
2
+
3
+ Ports packages/engine/src/utility.ts.
4
+
5
+ U = (F x 0.25) + (I x 0.25) + (R x 0.20) + (P x 0.15) + (T x 0.15)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+
12
+ from .schema import LedgerEntry
13
+ from .ledger import ENTRY_CAPS, TOTAL_CAP
14
+
15
+
16
+ def utility_score(
17
+ entry_data: dict,
18
+ now: datetime | None = None,
19
+ ) -> float:
20
+ """Compute utility score for a ledger entry data dict."""
21
+ if now is None:
22
+ now = datetime.now(timezone.utc)
23
+
24
+ last_str = entry_data.get("last", "")
25
+ last = datetime.fromisoformat(last_str) if last_str else now
26
+ if last.tzinfo is None:
27
+ last = last.replace(tzinfo=timezone.utc)
28
+ if now.tzinfo is None:
29
+ now = now.replace(tzinfo=timezone.utc)
30
+
31
+ days_since_last = max(0.0, (now - last).total_seconds() / 86400)
32
+
33
+ frequency = min(1.0, (entry_data.get("_uses_30d", 0)) / 10)
34
+ impact = entry_data.get("_impact", 0.5)
35
+ recency = max(0.0, 1.0 - days_since_last / 180)
36
+
37
+ n = entry_data.get("n", entry_data.get("total", 0))
38
+ ctd = entry_data.get("ctd", 0)
39
+
40
+ portability = 0.0
41
+ src = entry_data.get("src")
42
+ if src and isinstance(src, dict):
43
+ portability = sum(1 for v in src.values() if isinstance(v, (int, float)) and v > 0) / 3
44
+
45
+ stability = (1.0 - (ctd / n)) if n > 0 else 0.5
46
+
47
+ return (frequency * 0.25) + (impact * 0.25) + (recency * 0.20) + (portability * 0.15) + (stability * 0.15)
48
+
49
+
50
+ def is_protected(entry: LedgerEntry) -> bool:
51
+ """Check if an entry is protected from pruning."""
52
+ if entry.type == "meta":
53
+ return True
54
+ if entry.type == "rub" and entry.data.stage in ("proven", "trusted"):
55
+ return True
56
+ if entry.type == "pref" and entry.data.n >= 15 and entry.data.ctd == 0:
57
+ return True
58
+ return False
59
+
60
+
61
+ def _entry_data_dict(entry: LedgerEntry) -> dict:
62
+ """Get entry data as a plain dict for utility scoring."""
63
+ return entry.data.model_dump(exclude_none=True)
64
+
65
+
66
+ def prune_to_capacity(
67
+ entries: list[LedgerEntry],
68
+ now: datetime | None = None,
69
+ ) -> tuple[list[LedgerEntry], list[LedgerEntry]]:
70
+ """Prune entries to fit within capacity constraints.
71
+
72
+ Returns (kept, evicted). Removes lowest-utility entries first, never removes protected entries.
73
+ """
74
+ if now is None:
75
+ now = datetime.now(timezone.utc)
76
+
77
+ kept = list(entries)
78
+ evicted: list[LedgerEntry] = []
79
+
80
+ # Group by type
81
+ by_type: dict[str, list[tuple[int, LedgerEntry, float]]] = {}
82
+ for i, entry in enumerate(kept):
83
+ t = entry.type
84
+ if t not in by_type:
85
+ by_type[t] = []
86
+ by_type[t].append((i, entry, utility_score(_entry_data_dict(entry), now)))
87
+
88
+ # Enforce per-type caps
89
+ to_remove: set[int] = set()
90
+ for signal_type, items in by_type.items():
91
+ cap = ENTRY_CAPS.get(signal_type, 0) # type: ignore[arg-type]
92
+ if len(items) <= cap:
93
+ continue
94
+ items.sort(key=lambda x: x[2]) # sort by utility ascending
95
+ removed = 0
96
+ for idx, entry, _ in items:
97
+ if len(items) - removed <= cap:
98
+ break
99
+ if not is_protected(entry):
100
+ to_remove.add(idx)
101
+ removed += 1
102
+
103
+ # Remove in reverse order
104
+ for idx in sorted(to_remove, reverse=True):
105
+ evicted.append(kept.pop(idx))
106
+
107
+ # Enforce total cap
108
+ while len(kept) > TOTAL_CAP:
109
+ lowest_idx = -1
110
+ lowest_utility = float("inf")
111
+ for i, entry in enumerate(kept):
112
+ if is_protected(entry):
113
+ continue
114
+ u = utility_score(_entry_data_dict(entry), now)
115
+ if u < lowest_utility:
116
+ lowest_utility = u
117
+ lowest_idx = i
118
+ if lowest_idx == -1:
119
+ break
120
+ evicted.append(kept.pop(lowest_idx))
121
+
122
+ return kept, evicted
@@ -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,10 @@
1
+ nomark_engine/__init__.py,sha256=DMUE6ad1p6V71SbQKtoQfj7eyKHnaWrrDtvBgBwOyMo,2110
2
+ nomark_engine/classifier.py,sha256=02X0FVNaseR4cP4NFanPc2l_U5M6_OaPKR0KdP-JPU4,3614
3
+ nomark_engine/decay.py,sha256=1vAdS5emQvnqyu3ArOqsc3MGsP1vLYj9uYRU2XcQKhM,1230
4
+ nomark_engine/ledger.py,sha256=Q4fAzONp3R6VQx7sCNZs65bOVTm0V4kco5HK45FzuPw,2627
5
+ nomark_engine/resolver.py,sha256=o1v2MaBn5Yh-X0bIFQOloToiOU8uF1mj8yEevGv7pyA,8527
6
+ nomark_engine/schema.py,sha256=7s4BuA_8OQjdKLvqn9ucm4xy2kdYA2gsYfQDh0l3-N8,4740
7
+ nomark_engine/utility.py,sha256=4rzQmiaYNgYSl_uDpIRbb1ClLXErmjgLtn4moysl67o,3865
8
+ nomark_engine-0.1.0.dist-info/METADATA,sha256=S7Xf8LLQwTqrANQAApRzW1jT_bu_VucrcZevzLKrIxc,2284
9
+ nomark_engine-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ nomark_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any