nthlayer-workers 2.0.1__tar.gz → 3.0.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.
Files changed (193) hide show
  1. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/PKG-INFO +2 -2
  2. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/pyproject.toml +6 -2
  3. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/prometheus.py +5 -22
  4. nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/__init__.py +1 -0
  5. nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/phase.py +423 -0
  6. nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/sampler.py +231 -0
  7. nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/state.py +124 -0
  8. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/worker.py +275 -49
  9. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/worker.py +13 -6
  10. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/PKG-INFO +2 -2
  11. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/SOURCES.txt +4 -0
  12. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/requires.txt +1 -1
  13. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_dependency_declarations.py +27 -0
  14. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/README.md +0 -0
  15. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/setup.cfg +0 -0
  16. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/__init__.py +0 -0
  17. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/cli.py +0 -0
  18. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/cli_output.py +0 -0
  19. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/__init__.py +0 -0
  20. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/cli.py +0 -0
  21. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/config.py +0 -0
  22. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/__init__.py +0 -0
  23. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/changes.py +0 -0
  24. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/dedup.py +0 -0
  25. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/engine.py +0 -0
  26. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/temporal.py +0 -0
  27. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/topology.py +0 -0
  28. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/__init__.py +0 -0
  29. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/protocol.py +0 -0
  30. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/severity.py +0 -0
  31. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/webhook.py +0 -0
  32. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/notifications.py +0 -0
  33. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/prometheus.py +0 -0
  34. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/reasoning.py +0 -0
  35. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/session.py +0 -0
  36. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/__init__.py +0 -0
  37. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/generator.py +0 -0
  38. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/model.py +0 -0
  39. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/token.py +0 -0
  40. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/state.py +0 -0
  41. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/__init__.py +0 -0
  42. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/protocol.py +0 -0
  43. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/sqlite.py +0 -0
  44. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/summary.py +0 -0
  45. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/__init__.py +0 -0
  46. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/protocol.py +0 -0
  47. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/tempo.py +0 -0
  48. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/topology.py +0 -0
  49. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/types.py +0 -0
  50. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/verdict_chain.py +0 -0
  51. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/worker.py +0 -0
  52. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/__init__.py +0 -0
  53. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/__main__.py +0 -0
  54. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_apply.py +0 -0
  55. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_gh.py +0 -0
  56. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_interactive.py +0 -0
  57. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_interactive_app.py +0 -0
  58. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_preview.py +0 -0
  59. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_trigger.py +0 -0
  60. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_yaml.py +0 -0
  61. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/cli.py +0 -0
  62. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/recommendations.py +0 -0
  63. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/retrospective.py +0 -0
  64. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/trends.py +0 -0
  65. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/worker.py +0 -0
  66. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/__init__.py +0 -0
  67. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/__main__.py +0 -0
  68. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/_parsing.py +0 -0
  69. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/__init__.py +0 -0
  70. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/_util.py +0 -0
  71. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/devin.py +0 -0
  72. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/gastown.py +0 -0
  73. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/protocol.py +0 -0
  74. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/webhook.py +0 -0
  75. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/__init__.py +0 -0
  76. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/loop.py +0 -0
  77. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/slos.py +0 -0
  78. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/verdict_calibration.py +0 -0
  79. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/cli.py +0 -0
  80. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/config.py +0 -0
  81. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/__init__.py +0 -0
  82. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/detector.py +0 -0
  83. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/protocol.py +0 -0
  84. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/governance/__init__.py +0 -0
  85. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/governance/engine.py +0 -0
  86. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/manifest.py +0 -0
  87. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/notifications.py +0 -0
  88. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/__init__.py +0 -0
  89. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/evaluator.py +0 -0
  90. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/router.py +0 -0
  91. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/__init__.py +0 -0
  92. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/protocol.py +0 -0
  93. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/sqlite.py +0 -0
  94. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/telemetry.py +0 -0
  95. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/__init__.py +0 -0
  96. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/classifier.py +0 -0
  97. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/promotion.py +0 -0
  98. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/trends/__init__.py +0 -0
  99. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/trends/tracker.py +0 -0
  100. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/types.py +0 -0
  101. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/__init__.py +0 -0
  102. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/__main__.py +0 -0
  103. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/api/__init__.py +0 -0
  104. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/assessment.py +0 -0
  105. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/cli.py +0 -0
  106. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/config.py +0 -0
  107. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/db/__init__.py +0 -0
  108. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/decision_records.py +0 -0
  109. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/__init__.py +0 -0
  110. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/discovery.py +0 -0
  111. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/__init__.py +0 -0
  112. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/backstage.py +0 -0
  113. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/base.py +0 -0
  114. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/consul.py +0 -0
  115. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/etcd.py +0 -0
  116. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/kubernetes.py +0 -0
  117. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/prometheus.py +0 -0
  118. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/zookeeper.py +0 -0
  119. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/deployments/__init__.py +0 -0
  120. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/__init__.py +0 -0
  121. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/classifier.py +0 -0
  122. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/client.py +0 -0
  123. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/models.py +0 -0
  124. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/__init__.py +0 -0
  125. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/analyzer.py +0 -0
  126. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/models.py +0 -0
  127. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/patterns.py +0 -0
  128. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/explanation.py +0 -0
  129. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/__init__.py +0 -0
  130. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/conditions.py +0 -0
  131. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/correlator.py +0 -0
  132. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/evaluator.py +0 -0
  133. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/policies.py +0 -0
  134. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate_adapter.py +0 -0
  135. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/incident.py +0 -0
  136. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/__init__.py +0 -0
  137. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/aggregator.py +0 -0
  138. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/scorer.py +0 -0
  139. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/__init__.py +0 -0
  140. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/collector.py +0 -0
  141. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/spec_loader.py +0 -0
  142. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/sqlite_store.py +0 -0
  143. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/store.py +0 -0
  144. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/__init__.py +0 -0
  145. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/exporter_guidance.py +0 -0
  146. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/extractor.py +0 -0
  147. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/models.py +0 -0
  148. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/verifier.py +0 -0
  149. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/worker.py +0 -0
  150. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/__init__.py +0 -0
  151. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/__main__.py +0 -0
  152. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/__init__.py +0 -0
  153. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/base.py +0 -0
  154. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/communication.py +0 -0
  155. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/investigation.py +0 -0
  156. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/remediation.py +0 -0
  157. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/response_models.py +0 -0
  158. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/triage.py +0 -0
  159. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/cli.py +0 -0
  160. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/config.py +0 -0
  161. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/context_store.py +0 -0
  162. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/coordinator.py +0 -0
  163. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/metrics.py +0 -0
  164. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/__init__.py +0 -0
  165. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/ntfy_backend.py +0 -0
  166. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/protocol.py +0 -0
  167. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/slack_backend.py +0 -0
  168. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/stdout_backend.py +0 -0
  169. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notifications.py +0 -0
  170. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/__init__.py +0 -0
  171. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/escalation.py +0 -0
  172. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/runner.py +0 -0
  173. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/schedule.py +0 -0
  174. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  175. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/actions.py +0 -0
  176. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/registry.py +0 -0
  177. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/webhook.py +0 -0
  178. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/server.py +0 -0
  179. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/__init__.py +0 -0
  180. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/brief.py +0 -0
  181. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/delegation.py +0 -0
  182. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/post_incident.py +0 -0
  183. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/shift_report.py +0 -0
  184. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/suppression.py +0 -0
  185. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/types.py +0 -0
  186. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/verdict_submission.py +0 -0
  187. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/worker_helpers.py +0 -0
  188. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/runner.py +0 -0
  189. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/dependency_links.txt +0 -0
  190. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/entry_points.txt +0 -0
  191. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/top_level.txt +0 -0
  192. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_cli_gate.py +0 -0
  193. {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_runner.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nthlayer-workers
3
- Version: 2.0.1
3
+ Version: 3.0.0
4
4
  Summary: NthLayer workers — Tier 2 background computation (observe, measure, correlate, respond, learn)
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: nthlayer-common<4.0.0,>=3.0.0
7
+ Requires-Dist: nthlayer-common<6.0.0,>=5.2.0
8
8
  Requires-Dist: httpx>=0.27
9
9
  Requires-Dist: pyyaml>=6.0
10
10
  Requires-Dist: structlog>=24.1.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nthlayer-workers"
7
- version = "2.0.1"
7
+ version = "3.0.0"
8
8
  description = "NthLayer workers — Tier 2 background computation (observe, measure, correlate, respond, learn)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -47,7 +47,11 @@ dependencies = [
47
47
  # green. Still missing, and tracked as opensrm-p62o: the EXPECTED_MAJORS
48
48
  # half, which reads the BUILT wheel's own metadata in the release
49
49
  # container, where deps come from PyPI instead of the sibling checkout.
50
- "nthlayer-common>=3.0.0,<4.0.0",
50
+ # WHY 5.2.0 (opensrm-g32d): judgment thresholds are evaluated with
51
+ # nthlayer-common's evaluate_threshold (CORE §5.5.6) and read from core 3.0's
52
+ # /manifests via threshold_from_v2_entry; error magnitudes are no longer
53
+ # SLOs (decision 3c, common 4.0/5.0). See nthlayer-common/docs/upgrading-5.0.md.
54
+ "nthlayer-common>=5.2.0,<6.0.0",
51
55
  "httpx>=0.27",
52
56
  "pyyaml>=6.0",
53
57
  "structlog>=24.1.0",
@@ -96,7 +96,7 @@ def load_specs(specs_dir: Path) -> LoadedSpecs:
96
96
 
97
97
  Going through the parser also removes two reimplementations that had
98
98
  drifted: a four-name judgment-type list that disagreed with
99
- JUDGMENT_SLO_TYPES, and a target normalisation that duplicated the
99
+ the judgment SLO types, and a target normalisation that duplicated the
100
100
  0-100 convention nthlayer-common owns.
101
101
  """
102
102
  slos: list[SLODefinition] = []
@@ -155,7 +155,7 @@ def _query_for(service: str, slo: ManifestSLO) -> tuple[str, str]:
155
155
  name = slo.name
156
156
  if slo.judgment_type:
157
157
  # Keyed by judgment_type, not name: _JUDGMENT_SLO_QUERIES is a map of
158
- # judgment KINDS. Only 4 of the 8 JUDGMENT_SLO_TYPES have a builder;
158
+ # judgment KINDS. Only 2 of the 5 V2_JUDGMENT_SLO_TYPES have a builder;
159
159
  # the rest fall through to the recording-rule convention below rather
160
160
  # than yielding an empty query, which Prometheus 400s and which then
161
161
  # reads as no-data (opensrm-fxln).
@@ -176,8 +176,7 @@ def _query_for(service: str, slo: ManifestSLO) -> tuple[str, str]:
176
176
 
177
177
  # PromQL builders keyed by judgment TYPE (spec.judgment_type), not by the
178
178
  # SLO's name — in v2 the two are independent. Lambdas take (service,
179
- # window); `_window` on calibration/feedback_latency indicates those
180
- # queries are window-agnostic (the raw metric is already a gauge).
179
+ # window).
181
180
  _JUDGMENT_SLO_QUERIES = {
182
181
  "reversal_rate": lambda service, window: (
183
182
  f'sum(increase(gen_ai_overrides_total{{service="{service}"}}[{window}]))'
@@ -189,8 +188,6 @@ _JUDGMENT_SLO_QUERIES = {
189
188
  f' / '
190
189
  f'sum(increase(gen_ai_decisions_total{{service="{service}",confidence_bucket="high"}}[{window}]))'
191
190
  ),
192
- "calibration": lambda service, _window: f'gen_ai_calibration_error{{service="{service}"}}',
193
- "feedback_latency": lambda service, _window: f'gen_ai_feedback_latency_seconds{{service="{service}"}}',
194
191
  }
195
192
 
196
193
 
@@ -201,17 +198,6 @@ _JUDGMENT_SLO_QUERIES = {
201
198
  _JUDGMENT_QUERY_KINDS = {
202
199
  "reversal_rate": "judgment_rate",
203
200
  "high_confidence_failure": "judgment_rate",
204
- "calibration": "judgment_rate",
205
- # Unreachable today: feedback_latency is in opensrm's v1 schema.json and
206
- # in its CHANGELOG, but NOT in nthlayer-common's JUDGMENT_SLO_TYPES, and
207
- # parser/v1.py sets judgment_type only on membership. So a manifest
208
- # declaring it parses with judgment_type=None and lands on "ratio",
209
- # which multiplies a seconds gauge by 100. Kept rather than deleted
210
- # because the divergence is the bug (opensrm-vrpa) and this is the
211
- # correct handling once it is fixed;
212
- # test_feedback_latency_cannot_reach_its_own_branch is a strict xfail
213
- # that fails the day it becomes reachable.
214
- "feedback_latency": "judgment_duration",
215
201
  }
216
202
 
217
203
 
@@ -415,8 +401,8 @@ async def evaluate_slos(
415
401
 
416
402
  # Dispatch on the kind of number the query returns, decided by
417
403
  # _query_for when it built the query. Never on the SLO's name or
418
- # type: in v2 metadata.name is author-chosen, and 4 of the 8
419
- # JUDGMENT_SLO_TYPES have no builder, so both are the wrong
404
+ # type: in v2 metadata.name is author-chosen, and 3 of the 5
405
+ # V2_JUDGMENT_SLO_TYPES have no builder, so both are the wrong
420
406
  # question to ask about a value's units (opensrm-fxln).
421
407
  if slo.query_kind == "judgment_rate":
422
408
  # Prometheus returns a 0.0-1.0 RATE; the target is a 0-100
@@ -429,9 +415,6 @@ async def evaluate_slos(
429
415
  # overrides/decisions ratio, not to judgment SLOs at large.
430
416
  sli_pct = (1.0 - current_value) * 100
431
417
  raw_breach = sli_pct < slo.target
432
- elif slo.query_kind == "judgment_duration":
433
- # Seconds, and lower is better: no scaling, no inversion.
434
- raw_breach = current_value > slo.target
435
418
  elif slo.query_kind == "error_budget":
436
419
  # slo:error_budget:ratio is signed — negative is overspent.
437
420
  # The target is not consulted; it is baked into the rule.
@@ -0,0 +1 @@
1
+ """Judgment-threshold evaluation for the measure worker (opensrm-g32d)."""
@@ -0,0 +1,423 @@
1
+ """The judgment-threshold phase of the measure cycle (opensrm-g32d)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ import uuid
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ import structlog
13
+ from nthlayer_common.cloudevents import wrap_assessment, wrap_verdict
14
+ from nthlayer_common.manifest import (
15
+ JudgmentThreshold,
16
+ Missing,
17
+ OpenSRMV2ParseError,
18
+ ThresholdOutcome,
19
+ evaluate_threshold,
20
+ threshold_from_v2_entry,
21
+ threshold_to_v2_entry,
22
+ )
23
+
24
+ from nthlayer_workers.measure.thresholds.sampler import fetch_retention, needs_retention, sample
25
+ from nthlayer_workers.measure.thresholds.state import (
26
+ REARM_INTERVALS,
27
+ KeyState,
28
+ advance,
29
+ as_timestamp,
30
+ bounds_digest,
31
+ threshold_key,
32
+ )
33
+
34
+ logger = structlog.get_logger()
35
+
36
+ THRESHOLD_BREACH_SEVERITY = "high"
37
+ # Defaults for MeasureModule's MEASURE_THRESHOLD_INTERVAL / _HEARTBEAT.
38
+ DEFAULT_THRESHOLD_INTERVAL_SECONDS = 900.0
39
+ DEFAULT_THRESHOLD_HEARTBEAT_SECONDS = 3600.0
40
+ # MeasureModule bounds one threshold run by this (asyncio.wait_for), so a
41
+ # hanging Prometheus cannot hold the measure cycle (and its SLO phases).
42
+ THRESHOLD_PHASE_TIMEOUT_SECONDS = 120.0
43
+
44
+ _Entry = tuple[str, str, JudgmentThreshold] # (key, service, threshold)
45
+
46
+
47
+ def _log_once(seen: set[str], token: str, event: str, **kw: Any) -> None:
48
+ if token not in seen:
49
+ seen.add(token)
50
+ logger.warning(event, **kw)
51
+
52
+
53
+ def outcome_signature(outcome: ThresholdOutcome) -> str:
54
+ """The outcome without its measured values, as a JSON string with sorted
55
+ keys and sorted bound/segment lists: an assessment is re-emitted when
56
+ this changes (or on the heartbeat). A bound's declared limit is part of
57
+ it, so tightening a bound is reported even when no status changes."""
58
+ d = outcome.to_dict()
59
+ return json.dumps({
60
+ "type": d["type"],
61
+ "status": d["status"],
62
+ "bounds": sorted(([b["bound"], b["status"], b["reason"], b["limit"]] for b in d["bounds"]),
63
+ key=lambda row: row[0]),
64
+ "segments": sorted(([s["segment"], s["status"], s["reason"]] for s in d["segments"]),
65
+ key=lambda row: row[0]),
66
+ }, sort_keys=True)
67
+
68
+
69
+ def breach_readout(service: str, outcome: ThresholdOutcome) -> tuple[list[dict[str, Any]], str]:
70
+ """What a breach verdict says to a reader that does not parse the outcome
71
+ (respond's briefing, bench): (breaching_bounds, summary).
72
+
73
+ breaching_bounds lists each breaching row as {"bound", "measured",
74
+ "limit"}; for a segments threshold, one row per breaching segment, with
75
+ "segment" added and the segments bound's name and limit. summary is one
76
+ line about the first such row. A row decided from a lower bound (reason
77
+ "lower_bound") measured the boundary, not the value, so it reads
78
+ ">= limit (lower bound)". A Duration bound's measured value is seconds."""
79
+ rows: list[tuple[dict[str, Any], str | None]] = [] # (row, reason)
80
+ if outcome.type == "segments":
81
+ for b in outcome.bounds:
82
+ rows.extend(({"bound": b.bound, "segment": s.segment, "measured": s.measured,
83
+ "limit": b.limit}, s.reason)
84
+ for s in outcome.segments if s.status == "breach")
85
+ else:
86
+ rows.extend(({"bound": b.bound, "measured": b.measured, "limit": b.limit}, b.reason)
87
+ for b in outcome.bounds if b.status == "breach")
88
+ summary = f"{outcome.type} threshold breach on {service}"
89
+ if rows: # always, for an outcome whose status is breach
90
+ row, reason = rows[0]
91
+ # Segment names are author-chosen: escape control characters so the
92
+ # summary stays one line.
93
+ name = row["bound"] + (
94
+ f" (segment {row['segment'].encode('unicode_escape').decode('ascii')})"
95
+ if "segment" in row else "")
96
+ # A breach row always has a measured value (_decide sets it).
97
+ # 12 significant digits: a value just over its limit must not print as
98
+ # the limit (6-digit %g did), nor a long duration in exponent form.
99
+ value = f"{row['measured']:.12g}" + ("s" if isinstance(row["limit"], str) else "")
100
+ relation = (f">= {row['limit']} (lower bound)" if reason == "lower_bound"
101
+ else f"> {row['limit']}")
102
+ summary += f": {name} {value} {relation}"
103
+ return [row for row, _ in rows], summary
104
+
105
+
106
+ def _canonical(t: JudgmentThreshold) -> dict:
107
+ """The entry's v2 form for the identical-entry check, with segment_values
108
+ sorted: their order is not part of an entry's meaning (or its key)."""
109
+ entry = threshold_to_v2_entry(t)
110
+ if isinstance(entry.get("segment_values"), list):
111
+ entry = {**entry, "segment_values": sorted(entry["segment_values"], key=str)}
112
+ return entry
113
+
114
+
115
+ def _entries(manifests: list[dict], seen_problems: set[str]) -> tuple[set[str], list[_Entry]]:
116
+ """Parse and key every served threshold: (services present, entries).
117
+
118
+ "Present" means fully present, i.e. safe to retire against. A service is
119
+ left out, so every existing key of it is kept, when any of its manifests
120
+ has an entry that fails to parse, a judgment_thresholds that is not a
121
+ list, or no judgment_thresholds at all (a pre-3.0 core serves none: that
122
+ says nothing about which entries exist). An open breach must not retire
123
+ and re-raise because a response was briefly malformed or old. Cost: a
124
+ removed-and-broken entry lingers until the manifest parses cleanly or
125
+ the service disappears.
126
+
127
+ Identical entries are kept once. A threshold_key shared by several
128
+ distinct entries of a service (same identity, other bound values) is
129
+ suffixed for EVERY such entry with ~bounds_digest, so a key depends on
130
+ the entry, never on its position: reordering moves no state."""
131
+ present: set[str] = set()
132
+ partial: set[str] = set() # services with an entry that failed to parse
133
+ by_base: dict[str, list[tuple[dict, str, JudgmentThreshold]]] = {} # base -> distinct entries
134
+ for i, m in enumerate(manifests):
135
+ service = m.get("name") if isinstance(m, dict) else None
136
+ if not isinstance(service, str) or not service:
137
+ _log_once(seen_problems, f"manifest:{i}:{type(m).__name__}", "threshold_manifest_skipped",
138
+ where=f"/manifests[{i}]")
139
+ continue
140
+ items = m.get("judgment_thresholds")
141
+ if items is None:
142
+ # Absent (pre-3.0 core) or null: nothing to evaluate, and no
143
+ # evidence any entry was removed, so nothing of it retires.
144
+ partial.add(service)
145
+ continue
146
+ if not isinstance(items, list):
147
+ # Partial (not just "not present"): another manifest of the same
148
+ # name must not retire this service's keys either.
149
+ _log_once(seen_problems, f"thresholds:{service}:{type(items).__name__}",
150
+ "threshold_manifest_skipped", where=f"/manifests[{i}].judgment_thresholds")
151
+ partial.add(service)
152
+ continue
153
+ present.add(service)
154
+ for j, item in enumerate(items):
155
+ where = f"/manifests[{i}].judgment_thresholds[{j}]"
156
+ try:
157
+ t = threshold_from_v2_entry(item, where=where)
158
+ except OpenSRMV2ParseError as exc:
159
+ _log_once(seen_problems, f"parse:{service}:{item!r}", "threshold_parse_failed",
160
+ service=service, where=where, error=str(exc))
161
+ partial.add(service)
162
+ continue
163
+ base = threshold_key(service, t)
164
+ canonical = _canonical(t)
165
+ same_base = by_base.setdefault(base, [])
166
+ if any(c == canonical for c, _, _ in same_base): # identical: evaluate once
167
+ _log_once(seen_problems, f"dup:{base}", "threshold_duplicate", service=service, key=base)
168
+ continue
169
+ same_base.append((canonical, service, t))
170
+ entries: list[_Entry] = []
171
+ for base, distinct in by_base.items():
172
+ if len(distinct) == 1:
173
+ _, service, t = distinct[0]
174
+ entries.append((base, service, t))
175
+ continue
176
+ used: set[str] = set()
177
+ for _, service, t in distinct:
178
+ key = f"{base}~{bounds_digest(t)}"
179
+ if key in used: # a digest collision: keep the first, never merge state
180
+ _log_once(seen_problems, f"collision:{key}", "threshold_key_collision",
181
+ service=service, key=key)
182
+ continue
183
+ used.add(key)
184
+ entries.append((key, service, t))
185
+ return present - partial, entries
186
+
187
+
188
+ def _migrate(state: dict[str, dict], entries: list[_Entry]) -> dict[str, str]:
189
+ """Carry state across a re-key; returns {old_key: new_key}.
190
+
191
+ Adding or removing a same-identity sibling moves an entry between the
192
+ bare key and key~bounds_digest (and editing a suffixed sibling's bound
193
+ changes its digest). Without this the old key retires and the new one
194
+ starts armed, so an open breach re-fires and, with autonomy on, steps
195
+ the one-way ratchet again.
196
+
197
+ Per service and base key, each live key with no state is matched to a
198
+ non-live state key of the same service and base: first by equal stored
199
+ bounds_digest (when exactly one such key has it); then, if exactly one
200
+ old and one new key remain unmatched, the two pair (a bound edited while
201
+ re-keyed). Anything else is ambiguous and starts fresh, the safe
202
+ direction (it may re-raise). State without a stored base and digest
203
+ (written before they existed) is never migrated. Runs before
204
+ evaluation, so a migrated key is evaluated from its carried state.
205
+
206
+ First, every live key that already has state gets its stored base and
207
+ digest refreshed from its current entry. A bound edited while the key
208
+ stays the same (a lone entry's key ignores bound values), on a cycle
209
+ that does not evaluate it, or whose evaluation keeps raising, would
210
+ otherwise leave a stale digest that a later split cannot match; this
211
+ also gives state restored from before these fields existed an identity."""
212
+ for key, service, t in entries:
213
+ v = state.get(key)
214
+ if isinstance(v, dict):
215
+ v["base"], v["bounds_digest"] = threshold_key(service, t), bounds_digest(t)
216
+ live = {key for key, _, _ in entries}
217
+ new: dict[tuple[str, str], list[tuple[str, str]]] = {} # (service, base) -> [(key, digest)]
218
+ for key, service, t in entries:
219
+ if key not in state:
220
+ new.setdefault((service, threshold_key(service, t)), []).append((key, bounds_digest(t)))
221
+ old: dict[tuple[str, str], list[tuple[str, str]]] = {} # (service, base) -> [(key, digest)]
222
+ for key, v in state.items():
223
+ if key in live or not isinstance(v, dict):
224
+ continue
225
+ service, base, digest = v.get("service"), v.get("base"), v.get("bounds_digest")
226
+ if isinstance(service, str) and isinstance(base, str) and isinstance(digest, str):
227
+ old.setdefault((service, base), []).append((key, digest))
228
+ renamed: dict[str, str] = {}
229
+ for group, new_keys in new.items():
230
+ olds = old.get(group, [])
231
+ if not olds:
232
+ continue
233
+ pairs: list[tuple[str, str, str]] = [] # (old_key, new_key, new_digest)
234
+ for new_key, digest in new_keys:
235
+ same = [k for k, d in olds if d == digest]
236
+ if len(same) == 1:
237
+ pairs.append((same[0], new_key, digest))
238
+ matched_old = {o for o, _, _ in pairs}
239
+ if len(matched_old) != len(pairs): # two new keys claimed one old key
240
+ pairs = []
241
+ matched_old = set()
242
+ matched_new = {n for _, n, _ in pairs}
243
+ rest_old = [k for k, _ in olds if k not in matched_old]
244
+ rest_new = [(k, d) for k, d in new_keys if k not in matched_new]
245
+ if len(rest_old) == 1 and len(rest_new) == 1:
246
+ pairs.append((rest_old[0], rest_new[0][0], rest_new[0][1]))
247
+ for old_key, new_key, digest in pairs:
248
+ state[new_key] = {**state.pop(old_key), "base": group[1], "bounds_digest": digest}
249
+ renamed[old_key] = new_key
250
+ logger.info("threshold_migrated", old_key=old_key, new_key=new_key)
251
+ return renamed
252
+
253
+
254
+ async def _evaluate(
255
+ *, client: Any, provider: Any, key: str, service: str, t: JudgmentThreshold,
256
+ state: dict[str, dict], retention: float | Missing | None, retention_check: bool,
257
+ now: float, rearm_seconds: float, heartbeat_seconds: float,
258
+ ) -> tuple[str, str, str] | None:
259
+ """Sample, evaluate, report one entry; returns its new breach, if any.
260
+ state[key] changes only once everything this run owes is delivered."""
261
+ outcome = evaluate_threshold(
262
+ t, await sample(provider, service, t, now=now, retention=retention,
263
+ retention_check=retention_check)
264
+ )
265
+ prev = state.get(key) or {}
266
+ new_state, fire = advance(KeyState.from_dict(prev), outcome.status, now, rearm_seconds)
267
+ signature = outcome_signature(outcome)
268
+ last_signature = prev.get("last_signature")
269
+ last_emitted_at = as_timestamp(prev.get("last_emitted_at"))
270
+ # A firing breach always emits: its verdict's parent_ids name this assessment.
271
+ # A last emission in the future (the clock went back) emits too, so a
272
+ # backward clock cannot silence the heartbeat.
273
+ emit = (
274
+ fire
275
+ or signature != last_signature
276
+ or last_emitted_at is None
277
+ or last_emitted_at > now
278
+ or now - last_emitted_at >= heartbeat_seconds
279
+ )
280
+ assessment_id = f"jte-{service}-{t.type}-{uuid.uuid4().hex[:8]}"
281
+ if emit:
282
+ assessment = {
283
+ "id": assessment_id,
284
+ "created_at": datetime.now(UTC).isoformat(),
285
+ "kind": "judgment_threshold_evaluation",
286
+ "service": service,
287
+ "data": {"threshold_key": key, "outcome": outcome.to_dict()},
288
+ }
289
+ result = await client.submit_assessment(wrap_assessment(assessment, component="measure"))
290
+ if result.ok:
291
+ last_signature, last_emitted_at = signature, now
292
+ else:
293
+ # Not recorded as emitted, so the next run emits again. A breach
294
+ # still raises its verdict, whose parent_ids then name an
295
+ # assessment core did not store.
296
+ logger.warning("threshold_assessment_submit_failed", service=service, key=key,
297
+ assessment_id=assessment_id, status_code=result.status_code)
298
+ breach: tuple[str, str, str] | None = None
299
+ if fire:
300
+ breach_id = f"vrd-tbreach-{service}-{t.type}-{uuid.uuid4().hex[:8]}"
301
+ breaching_bounds, summary = breach_readout(service, outcome)
302
+ verdict = {
303
+ "id": breach_id,
304
+ "created_at": datetime.now(UTC).isoformat(),
305
+ "type": "quality_breach",
306
+ "service": service,
307
+ "parent_ids": [assessment_id],
308
+ "data": {"threshold_key": key, "threshold_type": t.type,
309
+ "severity": THRESHOLD_BREACH_SEVERITY, "outcome": outcome.to_dict(),
310
+ "breaching_bounds": breaching_bounds, "summary": summary},
311
+ }
312
+ sent = await client.submit_verdict(wrap_verdict(verdict, component="measure"))
313
+ if not sent.ok:
314
+ # Keep the previous state, so the next run sees the same
315
+ # transition and raises the verdict (and its assessment) again.
316
+ logger.warning("threshold_breach_submit_failed", service=service, key=key,
317
+ status_code=sent.status_code)
318
+ return None
319
+ breach = (key, service, breach_id)
320
+ state[key] = {**new_state.to_dict(), "service": service,
321
+ "base": threshold_key(service, t), "bounds_digest": bounds_digest(t),
322
+ "last_signature": last_signature, "last_emitted_at": last_emitted_at}
323
+ return breach
324
+
325
+
326
+ async def run_threshold_phase(
327
+ *, client: Any, provider: Any, manifests: list[dict], state: dict[str, dict],
328
+ seen_problems: set[str], eval_concurrency: int = 5, retention_check: bool = True,
329
+ now: float | None = None,
330
+ rearm_seconds: float = REARM_INTERVALS * DEFAULT_THRESHOLD_INTERVAL_SECONDS,
331
+ heartbeat_seconds: float = DEFAULT_THRESHOLD_HEARTBEAT_SECONDS,
332
+ renamed: dict[str, str] | None = None,
333
+ breaches_out: list[tuple[str, str, str]] | None = None,
334
+ unreached: set[str] | None = None,
335
+ rotation: int = 0,
336
+ ) -> list[tuple[str, str, str]]:
337
+ """Evaluate every served threshold; mutate *state*; return new breaches as
338
+ (key, service, breach_verdict_id). Caller guarantees *manifests* came from a
339
+ successful /manifests response. Entries are evaluated concurrently, at most
340
+ *eval_concurrency* at a time; one that raises is logged and skipped. An
341
+ assessment is emitted on first sight, when the outcome's signature
342
+ changes, after *heartbeat_seconds*, or whenever a breach fires.
343
+
344
+ Out-parameters, filled as the run goes so a caller that cancels it (the
345
+ worker's timeout) still sees them: *renamed* gets {old_key: new_key}
346
+ for state carried across a re-key (before any evaluation); each breach
347
+ whose verdict core accepted is appended to *breaches_out* at once, since
348
+ its state has advanced and it will never fire again. *unreached* is
349
+ filled with every key, and each is removed as its evaluation starts, so
350
+ after a cancellation it holds the keys never started.
351
+
352
+ Evaluation starts at entry *rotation* % len(entries) (the worker passes
353
+ a per-run counter), so a time budget that cuts a slow run short does not
354
+ always cut off the same tail. Order only: keys and state are unaffected."""
355
+ now = time.time() if now is None else now
356
+ present_services, entries = _entries(manifests, seen_problems)
357
+ moved = _migrate(state, entries)
358
+ if renamed is not None:
359
+ renamed.update(moved)
360
+ # Prometheus's retention, queried once per run and only if needed.
361
+ retention: float | Missing | None = None
362
+ if retention_check and any(needs_retention(t) for _, _, t in entries):
363
+ retention = await fetch_retention(provider)
364
+ sem = asyncio.Semaphore(eval_concurrency)
365
+ new_breaches = breaches_out if breaches_out is not None else []
366
+ start = rotation % len(entries) if entries else 0
367
+ ordered = entries[start:] + entries[:start]
368
+ if unreached is not None:
369
+ unreached.update(key for key, _, _ in ordered)
370
+
371
+ async def one(key: str, service: str, t: JudgmentThreshold) -> None:
372
+ async with sem:
373
+ if unreached is not None:
374
+ unreached.discard(key)
375
+ try:
376
+ breach = await _evaluate(client=client, provider=provider, key=key, service=service,
377
+ t=t, state=state, retention=retention,
378
+ retention_check=retention_check, now=now,
379
+ rearm_seconds=rearm_seconds,
380
+ heartbeat_seconds=heartbeat_seconds)
381
+ except Exception as exc: # one bad threshold affects only itself
382
+ _log_once(seen_problems, f"eval:{key}:{type(exc).__name__}", "threshold_eval_failed",
383
+ service=service, key=key, error=f"{type(exc).__name__}: {exc}")
384
+ return
385
+ # No await between the accepted verdict and this append, so a
386
+ # cancellation cannot drop an accepted breach.
387
+ if breach is not None:
388
+ new_breaches.append(breach)
389
+
390
+ # The semaphore admits waiters first-come, so gather's order is the
391
+ # evaluation order.
392
+ await asyncio.gather(*(one(*e) for e in ordered))
393
+ _retire(state, present_services, {key for key, _, _ in entries})
394
+ return list(new_breaches)
395
+
396
+
397
+ def _retire(state: dict[str, dict], present_services: set[str], live_keys: set[str]) -> None:
398
+ """Retire only keys of services present in a successful response whose
399
+ entry is gone. An entry that is not a dict, or has no str service
400
+ (corrupt state), can never match a manifest: drop it rather than let it
401
+ fail every run."""
402
+ for key in [k for k, v in state.items()
403
+ if not isinstance(v, dict) or not isinstance(v.get("service"), str)
404
+ or (v["service"] in present_services and k not in live_keys)]:
405
+ del state[key]
406
+ logger.info("threshold_retired", key=key)
407
+
408
+
409
+ def retire_thresholds(
410
+ *, manifests: list[dict], state: dict[str, dict], seen_problems: set[str],
411
+ renamed: dict[str, str] | None = None,
412
+ ) -> None:
413
+ """Garbage collection alone, for measure cycles on which the threshold
414
+ phase does not run: /manifests is fetched every cycle, so a removed
415
+ entry retires (and leaves governance) without waiting for the next
416
+ threshold run. Same rule as the phase: successful response, service
417
+ present, entry gone; and the same re-key migration first (*renamed*
418
+ gets {old_key: new_key}). Parses only; sends no query."""
419
+ present_services, entries = _entries(manifests, seen_problems)
420
+ moved = _migrate(state, entries)
421
+ if renamed is not None:
422
+ renamed.update(moved)
423
+ _retire(state, present_services, {key for key, _, _ in entries})