nthlayer-workers 1.0.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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,170 @@
1
+ """Snapshot generator with token budget, priority tiers, and caching."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ from nthlayer_workers.correlate.snapshot.token import CharDivFourEstimator, TokenEstimator
10
+ from nthlayer_workers.correlate.types import AgentState, CorrelationGroup
11
+
12
+
13
+ @dataclass
14
+ class SnapshotBudget:
15
+ max_tokens: int = 4000
16
+ reserved_instructions: int = 500
17
+ reserved_history: int = 500
18
+
19
+ @property
20
+ def available_for_groups(self) -> int:
21
+ return self.max_tokens - self.reserved_instructions - self.reserved_history
22
+
23
+
24
+ class SnapshotGenerator:
25
+ def __init__(
26
+ self,
27
+ budget: SnapshotBudget | None = None,
28
+ token_estimator: TokenEstimator | None = None,
29
+ cache_ttl_seconds: int = 900, # 15 min default
30
+ ):
31
+ self._budget = budget or SnapshotBudget()
32
+ self._estimator = token_estimator or CharDivFourEstimator()
33
+ self._cache_ttl = cache_ttl_seconds
34
+ self._cached_hash: str | None = None
35
+ self._cached_prompt: str | None = None
36
+ self._cache_time: float = 0.0
37
+
38
+ def generate(
39
+ self,
40
+ groups: list[CorrelationGroup],
41
+ state: AgentState = AgentState.WATCHING,
42
+ service_context: dict | None = None,
43
+ force_refresh: bool = False,
44
+ ) -> tuple[str, bool]:
45
+ """Generate model prompt from correlation groups.
46
+
47
+ Returns (model_prompt, cache_hit).
48
+ If cache_hit is True, model_prompt is the cached prompt.
49
+ """
50
+ # Compute content hash
51
+ content_hash = self._compute_hash(groups)
52
+
53
+ # Check cache (skip in INCIDENT mode)
54
+ if not force_refresh and state != AgentState.INCIDENT:
55
+ if self._is_cache_valid(content_hash, state):
56
+ return self._cached_prompt or "", True
57
+
58
+ # Sort groups by priority (P0 first)
59
+ sorted_groups = sorted(groups, key=lambda g: g.priority)
60
+
61
+ # Apply token budget
62
+ included_groups = self._apply_budget(sorted_groups)
63
+
64
+ # Assemble prompt
65
+ prompt = self._assemble_prompt(included_groups, service_context)
66
+
67
+ # Update cache
68
+ self._cached_hash = content_hash
69
+ self._cached_prompt = prompt
70
+ self._cache_time = time.monotonic()
71
+
72
+ return prompt, False
73
+
74
+ def _compute_hash(self, groups: list[CorrelationGroup]) -> str:
75
+ """SHA256 of stable group content (services, event counts, priority)."""
76
+ parts = sorted(
77
+ f"{','.join(sorted(g.services))}:{g.event_count}:{g.priority}"
78
+ for g in groups
79
+ )
80
+ return hashlib.sha256("|".join(parts).encode()).hexdigest()
81
+
82
+ def _is_cache_valid(self, content_hash: str, state: AgentState) -> bool:
83
+ """Check if cached snapshot is still valid."""
84
+ if self._cached_hash is None:
85
+ return False
86
+ if content_hash != self._cached_hash:
87
+ return False
88
+ # Check TTL
89
+ elapsed = time.monotonic() - self._cache_time
90
+ ttl = {
91
+ AgentState.WATCHING: 900,
92
+ AgentState.ALERT: 300,
93
+ AgentState.INCIDENT: 0, # never cache
94
+ AgentState.DEGRADED: 600,
95
+ }.get(state, self._cache_ttl)
96
+ return elapsed < ttl
97
+
98
+ def _apply_budget(self, sorted_groups: list[CorrelationGroup]) -> list[CorrelationGroup]:
99
+ """Include groups until token budget exhausted. P0 always included."""
100
+ included = []
101
+ tokens_used = 0
102
+ budget = self._budget.available_for_groups
103
+
104
+ for group in sorted_groups:
105
+ group_text = self._serialize_group(group)
106
+ group_tokens = self._estimator.estimate(group_text)
107
+
108
+ if group.priority == 0:
109
+ # P0 always included, even over budget
110
+ included.append(group)
111
+ tokens_used += group_tokens
112
+ elif tokens_used + group_tokens <= budget:
113
+ included.append(group)
114
+ tokens_used += group_tokens
115
+ # else: drop (never truncate)
116
+
117
+ return included
118
+
119
+ def _serialize_group(self, group: CorrelationGroup) -> str:
120
+ """Serialize a group to text for token counting and prompt inclusion."""
121
+ lines = [
122
+ f"## Correlation Group: {group.summary}",
123
+ f"Priority: P{group.priority}",
124
+ f"Services: {', '.join(group.services)}",
125
+ f"Events: {group.event_count}",
126
+ f"First seen: {group.first_seen}",
127
+ ]
128
+
129
+ if group.change_candidates:
130
+ lines.append("Change candidates:")
131
+ for cc in group.change_candidates:
132
+ proximity_min = cc.temporal_proximity_seconds / 60
133
+ relation = "same service" if cc.same_service else "dependency"
134
+ lines.append(
135
+ f" - {cc.change.payload.get('change_type', 'unknown')} on "
136
+ f"{cc.affected_service} ({proximity_min:.0f}m ago, {relation})"
137
+ )
138
+
139
+ if group.topology:
140
+ lines.append(f"Topology: {' -> '.join(group.topology.topology_path)}")
141
+
142
+ return "\n".join(lines)
143
+
144
+ def _assemble_prompt(
145
+ self,
146
+ groups: list[CorrelationGroup],
147
+ service_context: dict | None,
148
+ ) -> str:
149
+ """Assemble the full model prompt."""
150
+ parts = []
151
+
152
+ if not groups:
153
+ parts.append("No elevated correlation groups detected. System appears healthy.")
154
+ return "\n\n".join(parts)
155
+
156
+ parts.append(f"Analyzing {len(groups)} correlation group(s):\n")
157
+
158
+ for group in groups:
159
+ parts.append(self._serialize_group(group))
160
+
161
+ if service_context:
162
+ parts.append("\nService context:")
163
+ parts.append(json.dumps(service_context, indent=2))
164
+
165
+ return "\n\n".join(parts)
166
+
167
+ def invalidate_cache(self) -> None:
168
+ """Manually invalidate the cache (e.g., on state transition)."""
169
+ self._cached_hash = None
170
+ self._cached_prompt = None
@@ -0,0 +1,177 @@
1
+ """Model interface — the ZFC judgment boundary.
2
+
3
+ Used by: the `serve` and `replay` CLI subcommands (continuous snapshot generation).
4
+ See also: reasoning.py which serves the `correlate` subcommand (live triggered).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import structlog
10
+ from pathlib import Path
11
+
12
+ from nthlayer_common.prompts import load_prompt
13
+ from nthlayer_workers.correlate.types import CorrelationGroup
14
+
15
+ _PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "snapshot.yaml"
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ class ModelInterface:
21
+ def __init__(self, model: str | None = None, max_tokens: int = 4096):
22
+ self._model = model # None → llm_call uses NTHLAYER_MODEL env var
23
+ self._max_tokens = max_tokens
24
+
25
+ async def interpret(
26
+ self,
27
+ prompt: str,
28
+ groups: list[CorrelationGroup],
29
+ verdict_store=None,
30
+ ) -> list:
31
+ """Call model, parse response into verdicts.
32
+
33
+ Returns list of Verdict objects (children + parent).
34
+ If model call fails, returns template-based verdicts with confidence 0.0.
35
+ """
36
+ from nthlayer_common.verdicts import create as verdict_create
37
+
38
+ try:
39
+ response = await self._call_model(prompt)
40
+ assessments = self._parse_response(response, groups)
41
+ except Exception as e:
42
+ logger.warning("model_call_failed", error=str(e))
43
+ return self._create_template_verdicts(groups)
44
+
45
+ # Create per-correlation verdicts
46
+ child_verdicts = []
47
+ for assessment in assessments:
48
+ v = verdict_create(
49
+ subject={
50
+ "type": "correlation",
51
+ "service": assessment.get("service"),
52
+ "ref": assessment.get("group_id", ""),
53
+ "summary": assessment.get("summary", ""),
54
+ },
55
+ judgment={
56
+ "action": assessment.get("action", "flag"),
57
+ "confidence": assessment.get("confidence", 0.5),
58
+ "reasoning": assessment.get("reasoning", ""),
59
+ "tags": assessment.get("tags", []),
60
+ },
61
+ producer={"system": "nthlayer-correlate", "model": self._model},
62
+ )
63
+ child_verdicts.append(v)
64
+ if verdict_store:
65
+ verdict_store.put(v)
66
+
67
+ # Create parent snapshot verdict
68
+ overall_action = "escalate" if any(
69
+ a.get("action") == "escalate" for a in assessments
70
+ ) else "flag"
71
+ overall_confidence = (
72
+ sum(a.get("confidence", 0.5) for a in assessments) / len(assessments)
73
+ if assessments else 0.0
74
+ )
75
+
76
+ # Collect state transition tags
77
+ tags = []
78
+ for a in assessments:
79
+ tags.extend(a.get("tags", []))
80
+
81
+ parent = verdict_create(
82
+ subject={
83
+ "type": "correlation",
84
+ "service": None,
85
+ "ref": "snapshot",
86
+ "summary": f"Snapshot: {len(groups)} group(s), {len(assessments)} assessed",
87
+ },
88
+ judgment={
89
+ "action": overall_action,
90
+ "confidence": overall_confidence,
91
+ "reasoning": f"Assessed {len(assessments)} correlation group(s)",
92
+ "tags": list(set(tags)),
93
+ },
94
+ producer={"system": "nthlayer-correlate", "model": self._model},
95
+ )
96
+ parent.lineage.children = [v.id for v in child_verdicts]
97
+ if verdict_store:
98
+ verdict_store.put(parent)
99
+
100
+ return child_verdicts + [parent]
101
+
102
+ async def _call_model(self, prompt: str) -> str:
103
+ """Call the LLM via the shared nthlayer-common wrapper."""
104
+ import asyncio
105
+ from nthlayer_common.llm import llm_call
106
+
107
+ result = await asyncio.to_thread(
108
+ llm_call,
109
+ system=self._build_system_prompt(),
110
+ user=prompt,
111
+ model=self._model,
112
+ max_tokens=self._max_tokens,
113
+ )
114
+ return result.text
115
+
116
+ def _build_system_prompt(self) -> str:
117
+ spec = load_prompt(_PROMPT_PATH)
118
+ return spec.system
119
+
120
+ def _parse_response(self, response_text: str, groups: list[CorrelationGroup]) -> list[dict]:
121
+ """Parse model JSON response."""
122
+ from nthlayer_common.parsing import clamp, strip_markdown_fences
123
+
124
+ text = strip_markdown_fences(response_text)
125
+ data = json.loads(text)
126
+ assessments = data.get("assessments", [])
127
+
128
+ # Validate actions and clamp confidence before verdict creation
129
+ valid_actions = {"flag", "escalate", "defer"}
130
+ for a in assessments:
131
+ if a.get("action") not in valid_actions:
132
+ a["action"] = "flag"
133
+ a["confidence"] = clamp(float(a.get("confidence", 0.5)))
134
+
135
+ return assessments
136
+
137
+ def _create_template_verdicts(self, groups: list[CorrelationGroup]) -> list:
138
+ """Degraded mode: template-based verdicts with confidence 0.0."""
139
+ from nthlayer_common.verdicts import create as verdict_create
140
+
141
+ child_verdicts = []
142
+ for group in groups:
143
+ v = verdict_create(
144
+ subject={
145
+ "type": "correlation",
146
+ "service": group.services[0] if group.services else None,
147
+ "ref": group.id,
148
+ "summary": group.summary,
149
+ },
150
+ judgment={
151
+ "action": "flag",
152
+ "confidence": 0.0,
153
+ "reasoning": "template-based, model unavailable",
154
+ "tags": ["degraded"],
155
+ },
156
+ producer={"system": "nthlayer-correlate"},
157
+ )
158
+ child_verdicts.append(v)
159
+
160
+ parent = verdict_create(
161
+ subject={
162
+ "type": "correlation",
163
+ "service": None,
164
+ "ref": "snapshot-degraded",
165
+ "summary": f"Degraded snapshot: {len(groups)} group(s), no model assessment",
166
+ },
167
+ judgment={
168
+ "action": "flag",
169
+ "confidence": 0.0,
170
+ "reasoning": "template-based, model unavailable",
171
+ "tags": ["degraded"],
172
+ },
173
+ producer={"system": "nthlayer-correlate"},
174
+ )
175
+ parent.lineage.children = [v.id for v in child_verdicts]
176
+
177
+ return child_verdicts + [parent]
@@ -0,0 +1,14 @@
1
+ """Token estimation. Protocol + simple char/4 estimator for Tier 1."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+
7
+ class TokenEstimator(Protocol):
8
+ def estimate(self, text: str) -> int: ...
9
+
10
+
11
+ class CharDivFourEstimator:
12
+ """len(text) // 4 — good enough for Tier 1."""
13
+ def estimate(self, text: str) -> int:
14
+ return len(text) // 4
@@ -0,0 +1,88 @@
1
+ """Agent state machine for SitRep."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+
6
+ from nthlayer_workers.correlate.types import AgentState, CorrelationGroup
7
+
8
+
9
+ class StateMachine:
10
+ """Deterministic state machine. Transitions are transport, not judgment."""
11
+
12
+ def __init__(self):
13
+ self.state = AgentState.WATCHING
14
+ self._alert_start: datetime | None = None
15
+ self._calm_since: datetime | None = None
16
+
17
+ def update(
18
+ self,
19
+ groups: list[CorrelationGroup],
20
+ model_healthy: bool = True,
21
+ ) -> AgentState:
22
+ """Evaluate state transition based on correlation output."""
23
+ now = datetime.now(timezone.utc)
24
+
25
+ # Model failure → DEGRADED (from any state except INCIDENT)
26
+ if not model_healthy and self.state != AgentState.INCIDENT:
27
+ self.state = AgentState.DEGRADED
28
+ self._calm_since = None
29
+ return self.state
30
+
31
+ # DEGRADED + model recovery → WATCHING
32
+ if self.state == AgentState.DEGRADED and model_healthy:
33
+ self.state = AgentState.WATCHING
34
+ self._calm_since = now
35
+ return self.state
36
+
37
+ has_p0 = any(g.priority == 0 for g in groups)
38
+ p1_count = sum(1 for g in groups if g.priority == 1)
39
+ has_elevated = has_p0 or p1_count >= 2
40
+
41
+ if self.state == AgentState.WATCHING:
42
+ if has_elevated:
43
+ self.state = AgentState.ALERT
44
+ self._alert_start = now
45
+ self._calm_since = None
46
+ elif self.state == AgentState.ALERT:
47
+ if has_elevated:
48
+ self._calm_since = None
49
+ else:
50
+ if self._calm_since is None:
51
+ self._calm_since = now
52
+ elif (now - self._calm_since).total_seconds() >= 600: # 10 min
53
+ self.state = AgentState.WATCHING
54
+ self._alert_start = None
55
+ self._calm_since = None
56
+
57
+ return self.state
58
+
59
+ def declare_incident(self) -> None:
60
+ """External incident declaration → INCIDENT."""
61
+ self.state = AgentState.INCIDENT
62
+ self._calm_since = None
63
+
64
+ def resolve_incident(self) -> None:
65
+ """Incident resolved → WATCHING."""
66
+ self.state = AgentState.WATCHING
67
+ self._alert_start = None
68
+ self._calm_since = datetime.now(timezone.utc)
69
+
70
+ def get_interval(self) -> int:
71
+ """Snapshot interval in seconds for current state."""
72
+ intervals = {
73
+ AgentState.WATCHING: 300,
74
+ AgentState.ALERT: 60,
75
+ AgentState.INCIDENT: 30,
76
+ AgentState.DEGRADED: 120,
77
+ }
78
+ return intervals[self.state]
79
+
80
+ def get_cache_ttl(self) -> int | None:
81
+ """Cache TTL in seconds. None = no caching (INCIDENT)."""
82
+ ttls = {
83
+ AgentState.WATCHING: 900, # 15 min
84
+ AgentState.ALERT: 300, # 5 min
85
+ AgentState.INCIDENT: None, # no caching
86
+ AgentState.DEGRADED: 600, # 10 min
87
+ }
88
+ return ttls[self.state]
@@ -0,0 +1,5 @@
1
+ """Event storage for SitRep."""
2
+ from nthlayer_workers.correlate.store.protocol import EventStore
3
+ from nthlayer_workers.correlate.store.sqlite import SQLiteEventStore
4
+
5
+ __all__ = ["EventStore", "SQLiteEventStore"]
@@ -0,0 +1,48 @@
1
+ """EventStore protocol — the storage contract for SitRep."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Protocol
5
+
6
+ from nthlayer_workers.correlate.types import EventType, SitRepEvent
7
+
8
+
9
+ class EventStore(Protocol):
10
+ """Protocol defining the storage interface for SitRep events."""
11
+
12
+ def insert(self, event: SitRepEvent) -> None: ...
13
+
14
+ def insert_batch(self, events: list[SitRepEvent]) -> None: ...
15
+
16
+ def get_by_time_window(
17
+ self,
18
+ start: str,
19
+ end: str,
20
+ *,
21
+ service: str | None = None,
22
+ event_type: EventType | None = None,
23
+ min_severity: float | None = None,
24
+ ) -> list[SitRepEvent]: ...
25
+
26
+ def search(
27
+ self,
28
+ query: str,
29
+ *,
30
+ limit: int = 100,
31
+ time_window: tuple[str, str] | None = None,
32
+ service: str | None = None,
33
+ ) -> list[SitRepEvent]: ...
34
+
35
+ def get_by_topology(
36
+ self, service: str, hops: int = 1
37
+ ) -> list[SitRepEvent]: ...
38
+
39
+ def get_recent_changes(
40
+ self, service: str, window_minutes: int = 30,
41
+ reference_time: str | None = None,
42
+ ) -> list[SitRepEvent]: ...
43
+
44
+ def expire_old(self) -> int: ...
45
+
46
+ def get_state_hash(self, time_window: tuple[str, str]) -> str: ...
47
+
48
+ def get_stats(self) -> dict[str, Any]: ...