Skip to content

Hypothesis stateful tests for the metrics event reducer (#78) - #247

Merged
leynos merged 11 commits into
mainfrom
python-metrics-adapter-tests
Aug 3, 2026
Merged

Hypothesis stateful tests for the metrics event reducer (#78)#247
leynos merged 11 commits into
mainfrom
python-metrics-adapter-tests

Conversation

@leynos

@leynos leynos commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

MetricsHook.__call__ was a phase-dispatch that reached straight into the collector, with no seam to verify which counters and histograms each event yields across varied phase/order/pid combinations (#78).

Seam

Extract the pure event-to-operation reducer _metric_operations(event) -> tuple[_MetricOp, ...]:

  • _CounterOp / _HistogramOp records describe the intended operations; a _PHASE_COUNTERS lookup collapses the unit-counter phases.
  • MetricsHook.__call__ now applies the reducer's operations, resolving labels only when there is at least one operation — so plan (and an unknown phase) still compute no labels, as the existing tests require.
  • The former _increment/_record_stdin_bytes/_record_exit helpers are removed; behaviour is unchanged and test_metrics_adapter.py still passes.

Tests

cuprum/unittests/test_metrics_adapter_stateful.py:

  • Property tests pin the operations produced per phase — unit counters for start/stdout/stderr/stdin_error; plan yields nothing; stdin yields a bytes counter only when a byte count is present; exit counts a failure only for a non-zero code and a duration only when measured; an unknown phase raises.
  • A Hypothesis RuleBasedStateMachine streams random events (all seven phases, phase-appropriate fields) through a real MetricsHook/InMemoryMetrics and, after every step, checks the accumulated counters and histograms against an independent phase-count oracle (not the reducer). This proves counters and observations are created exactly when intended, and only then, across arbitrary event orders.

Scope

#78 is titled "Hypothesis stateful tests for metrics/tracing/logging hooks".
Its body scopes the work to cuprum/adapters/metrics_adapter.py, but taking the
title at its word, all three adapters now have randomised event coverage:

Table 1: verification shape for each observe hook, and why

Adapter Coverage Shape, and why
tracing_adapter.py test_tracing_span_stateful.py (pre-existing) state machine — holds _active_spans, so correlation and drain are the risks
metrics_adapter.py test_metrics_adapter_stateful.py (added here) state machine — accumulates counters and histograms
logging_adapter.py test_logging_adapter_properties.py (added here) @given properties — holds no state at all

The logging hook gets properties rather than a fourth state machine because it
carries nothing between events: one record in, one record out. Interleavings
cannot distinguish any two implementations of it. Its risks are per-event and
shape-dependent — a reserved-LogRecord collision, a phase falling through the
level map, a tag value the JSON formatter cannot serialize — and the five
properties pin exactly those.

On active map draining: only TracingHook has an active map.
metrics_adapter.py has none, and neither does the logging hook, so the claim
applies to tracing alone — where test_tracing_span_stateful.py asserts it
directly, cross-checking hook._active_spans against a model after every step
and pinning that an exit removes only its own execution's span.

#252 was raised to track the logging work while it was still outstanding; it is
now delivered here and can be closed.

Closes #78

🤖 Generated with Claude Code

Summary by Sourcery

Extract a pure event-to-metrics reducer for the metrics hook and add property-based and stateful tests to verify metrics behavior across execution phases.

New Features:

  • Introduce a pure _metric_operations reducer that maps execution events to counter and histogram operations for the metrics hook.
  • Add Hypothesis-based property and stateful tests that validate metrics accumulation over randomized execution event streams.

Enhancements:

  • Refactor MetricsHook.__call__ to delegate to the shared reducer and a generic operation applier, avoiding label computation for no-op events.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @leynos, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary

  • Extract a pure _metric_operations reducer from MetricsHook, with explicit counter and histogram operations.
  • Preserve no-op behaviour for plan and uncounted stdin events. Retain errors for unknown phases.
  • Add Hypothesis property-based and stateful tests for metrics and structured logging.
  • Document the reducer, label-resolution flow, non-atomic operation application, and verification strategy in the design and developer guides.
  • Update the wheel-manifest snapshot for the new metrics test module.

Walkthrough

Reduce execution events into explicit counter or histogram operations. Apply those operations through MetricsHook. Validate metrics and logging behaviour with property-based and stateful tests. Document the reducer flow and update the wheel snapshot.

Changes

Metrics operation pipeline

Layer / File(s) Summary
Reduce events into metric operations
cuprum/adapters/metrics_adapter.py
Create immutable operations and reducers for phase-specific counters, optional stdin byte counts, exit failures, and durations. Raise _UnhandledMetricsPhaseError for unknown phases.
Apply operations through the metrics hook
cuprum/adapters/metrics_adapter.py
Reduce each event before extracting labels. Skip label extraction when no operations exist. Apply counter and histogram operations independently through MetricsCollector.
Verify and document adapter behaviour
cuprum/unittests/test_metrics_adapter_stateful.py, cuprum/unittests/test_logging_adapter_properties.py, docs/cuprum-design.md, docs/developers-guide.md, cuprum/unittests/__snapshots__/test_maturin_build.ambr
Add unit, property-based, stateful, and failure-path tests. Document the two-stage metrics flow and verification shapes. Record the new stateful test in the wheel snapshot.

Sequence Diagram(s)

sequenceDiagram
  participant ExecEvent
  participant MetricsHook
  participant metric_operations
  participant MetricsCollector
  ExecEvent->>MetricsHook: submit execution event
  MetricsHook->>metric_operations: reduce event
  metric_operations-->>MetricsHook: return operations
  MetricsHook->>MetricsCollector: apply counter or histogram operation
Loading

Suggested labels: Issue

Poem

Reduce each event to an operation,
Apply counters with clear separation.
Record bytes, failures, and time,
Test each phase in ordered rhyme.
Keep unknown phases in line.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 inconclusive)

Check name Status Explanation Resolution
Testing (Overall) ❓ Inconclusive Evidence gathering is still in progress. Inspect the complete adapter behaviour, existing tests, and generated-test execution before deciding.
Developer Documentation ❓ Inconclusive I need to inspect the repository files and history before I can assess documentation coverage. Provide the PR checkout or an accessible diff if the repository does not contain the proposed changes.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the metrics reducer tests and includes the linked issue reference (#78).
Description check ✅ Passed The description clearly explains the metrics reducer, randomized tests, logging coverage, and scope for issue #78.
Linked Issues check ✅ Passed The changes address issue #78 by adding metrics stateful tests, logging properties, reducer seams, and retaining tracing coverage.
Out of Scope Changes check ✅ Passed The implementation, tests, and documentation support the linked issue objectives without unrelated code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
User-Facing Documentation ✅ Passed Pass: the PR refactors existing metrics behaviour without changing the public API or emitted metrics; the users guide already documents the metrics adapter.
Module-Level Documentation ✅ Passed All 186 Python modules have module docstrings; the metrics adapter and both new property/stateful test modules clearly state their purpose, function, and component relationships.
Testing (Unit And Behavioural) ✅ Passed Accept the coverage: reducer edge/error tests, a real-hook state machine with an independent oracle, and command plus logging boundary tests use real collectors and handlers.
Testing (Property / Proof) ✅ Passed Hypothesis tests vary stdin counts and exit values; a RuleBasedStateMachine checks 60×20 event streams against an independent oracle, with five logging properties covering varied event shapes.
Testing (Compile-Time / Ui) ✅ Passed Pass this check: no Rust or TypeScript files changed; metrics and JSON logging use focused property/state assertions, and the wheel snapshot records the added test entries.
Unit Architecture ✅ Passed The reducer is side-effect free, while MetricsHook._apply performs explicit collector writes through an injected MetricsCollector; no hidden I/O, clock, network, or global mutable dependency was ad...
Domain Architecture ✅ Passed Keep the boundary: _metric_operations and MetricsHook remain in cuprum.adapters, use the injectable MetricsCollector protocol, and core modules import no adapter or vendor code.
Observability ✅ Passed The refactor preserves metric behaviour; _emit_exec_event logs phase, program, and error type, while docs define partial failures and metrics use only program/project labels.
Security And Privacy ✅ Passed The PR adds metric operation records, tests, and documentation; it introduces no secrets, auth changes, injection sinks, permissions, or new sensitive-data telemetry.
Performance And Resource Use ✅ Passed The production change uses O(1) phase lookup and applies at most two operations per event; new allocations are bounded per event, with no new I/O, retries, queues, or unbounded production state.
Concurrency And State ✅ Passed MetricsHook keeps only local immutable operations; collector thread safety and _LockedStore locking are explicit, while docs and tests cover ordered application and partial failure.
Architectural Complexity And Maintainability ✅ Passed The change adds no dependencies, registries, global mutable state, or cross-layer edges; the abstraction remains local to metrics_adapter.py.
Rust Compiler Lint Integrity ✅ Passed Keep compiler lint integrity: the merge-base diff contains no Rust files, adds no Rust suppressions or clone calls, and current Rust has only one narrow, issue-linked #[expect].
📋 Issue Planner

Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).

View plan for ticket: #78

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch python-metrics-adapter-tests

Comment @coderabbitai help to get the list of available commands.

codescene-access[bot]

This comment was marked as outdated.

@sourcery-ai

sourcery-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Refactors the metrics adapter to introduce a pure event-to-metric-operations reducer and wires MetricsHook.call through it, then adds focused property tests and a Hypothesis rule-based state machine to verify that metrics counters and histograms are produced exactly as intended across all execution phases and event streams.

Sequence diagram for MetricsHook event-to-operations reducer

sequenceDiagram
    participant ExecEvent
    participant MetricsHook
    participant Reducer as _metric_operations
    participant Collector as MetricsCollector

    ExecEvent ->> MetricsHook: __call__(event)
    MetricsHook ->> Reducer: _metric_operations(event)
    Reducer -->> MetricsHook: tuple[_MetricOp]

    alt no operations
        MetricsHook -->> ExecEvent: return
    else has operations
        MetricsHook ->> MetricsHook: _extract_labels(event)
        loop for each operation
            MetricsHook ->> MetricsHook: _apply(operation, labels)
            alt _CounterOp
                MetricsHook ->> Collector: inc_counter(name, value, labels)
            else _HistogramOp
                MetricsHook ->> Collector: observe_histogram(name, value, labels)
            end
        end
    end
Loading

File-Level Changes

Change Details Files
Introduce pure event-to-operation reducer and small operation types to decouple event logic from the metrics collector.
  • Define immutable _CounterOp and _HistogramOp dataclasses plus the _MetricOp union for describing intended metric operations.
  • Add the _PHASE_COUNTERS mapping for simple unit-counter phases and implement _exit_operations for exit-phase-specific logic.
  • Implement _metric_operations(event) as the single event-to-operations reducer, handling plan/stdin/exit phases and enforcing unknown phases via _UnhandledMetricsPhaseError.
cuprum/adapters/metrics_adapter.py
Rewire MetricsHook to use the reducer and a generic apply path, simplifying phase dispatch and label handling.
  • Replace the match/case phase dispatch in MetricsHook.call with a call to _metric_operations and early-return when there are no operations so labels are not computed for no-op events.
  • Introduce MetricsHook._apply to translate _CounterOp/_HistogramOp instances into collector calls, replacing the previous _increment/_record_stdin_bytes/_record_exit helpers.
  • Remove the old helper methods and ensure behavior remains equivalent by reusing the same counter and histogram names and values.
cuprum/adapters/metrics_adapter.py
Add property-based and stateful tests to pin the reducer’s behavior and cross-check real metric accumulation against an independent oracle.
  • Add focused tests that assert each known phase yields the correct operations, including unit-counter phases, plan/no-op behavior, stdin with/without byte_count, exit combinations of exit_code and duration_s, and unknown-phase error raising.
  • Introduce composable Hypothesis strategies for ExecEvent instances with phase-appropriate fields, used by both property tests and the state machine.
  • Implement a RuleBasedStateMachine that feeds random ExecEvents into a real MetricsHook backed by InMemoryMetrics while maintaining an independent per-phase counter/duration oracle, with invariants asserting the collector’s counters and histograms match the oracle at every step and only expected histograms exist.
  • Register the state machine as TestMetricsAccumulation with custom Hypothesis settings to control the number of examples and steps.
cuprum/unittests/test_metrics_adapter_stateful.py
Regenerate test snapshot metadata to reflect the updated wheel manifest.
  • Update the maturin build snapshot file so snapshot tests remain consistent with the current wheel manifest.
cuprum/unittests/__snapshots__/test_maturin_build.ambr

Assessment against linked issues

Issue Objective Addressed Explanation
#78 Extract a pure event-to-operation reducer from the metrics hook in cuprum/adapters/metrics_adapter.py to make metric behaviour verifiable independently of the collector.
#78 Add Hypothesis-based property and stateful tests for the metrics hook in cuprum/adapters/metrics_adapter.py to systematically exercise varied event phase/order combinations and prove counters and histograms are created exactly when intended.
#78 Add similar Hypothesis stateful tests for tracing and logging hooks (including proving active maps drain correctly) as referenced in the issue title. The PR explicitly scopes its work to the metrics adapter only, adding reducer extraction and stateful tests for MetricsHook and InMemoryMetrics. It does not modify or add tests for tracing or logging hooks, nor does it address active map draining.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot added the Issue label Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cuprum/adapters/metrics_adapter.py`:
- Line 200: Update the _MetricOp type alias to use the PEP 695 type statement
for the _CounterOp | _HistogramOp union, preserving the existing member types
and avoiding the legacy assignment syntax.
- Around line 226-249: Refactor `_metric_operations` to dispatch on
`event.phase` using a `match`/`case` statement rather than the current chained
top-level `if` branches. Preserve the existing behavior for `plan`, mapped
counter phases, `stdin` byte counts, `exit` via `_exit_operations`, and unknown
phases raising `_UnhandledMetricsPhaseError`; keep the nested `stdin` byte-count
check intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4739c9b9-c96c-4693-85fe-4df36863bc7f

📥 Commits

Reviewing files that changed from the base of the PR and between 302858c and 9d352de.

📒 Files selected for processing (3)
  • cuprum/adapters/metrics_adapter.py
  • cuprum/unittests/__snapshots__/test_maturin_build.ambr
  • cuprum/unittests/test_metrics_adapter_stateful.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • leynos/shared-actions (auto-detected)
  • leynos/pylint-pypy-shim (auto-detected)
  • leynos/whitaker (auto-detected)

Comment thread cuprum/adapters/metrics_adapter.py Outdated
Comment thread cuprum/adapters/metrics_adapter.py Outdated
codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

Raised #251 to number this diagram once both PRs land.

The §8.4 diagram here is deliberately unnumbered: #245 inserts two figures earlier in §8 and pushes the old 4 and 5 to 6 and 7, so the correct number for this one is Figure 6 if #247 merges first and Figure 8 once #245 lands. Any fixed number would be wrong under one of the two merge orders, so it carries only its screen-reader caption, which is never wrong. No prose cross-references figure numbers, so nothing breaks in the interim.

#251 records the expected end state (Figures 3–8 with sections and subjects) and asks for a check that #245's renumbering survived the merge, since both PRs touch §8 prose.

While auditing for that issue I found three adjacent things and put them in #251 as decisions rather than acting on them here:

  • docs/cuprum-design.md has 8 Mermaid diagrams but only 3 numbered figures; §8 numbers without screen-reader captions and §13 does the reverse.
  • One diagram (§"Implementation notes for the first iteration", ~line 548) has neither caption, which contravenes both "caption every diagram" and "add screen reader descriptions before complex diagrams" in the documentation style guide. That is the only real accessibility gap of the eight and is worth fixing regardless of the numbering decision.
  • Numbering starts at 3 — there is no Figure 1 or 2 — and the style guide's own example uses the italic _Figure N: ..._ form while this document uses plain Figure N:.

None of those are regressions from this PR, so they are flagged rather than folded in.

@pandalump

Copy link
Copy Markdown
Collaborator

Both findings verified against current code and fixed in bf74ced. Neither was stale.

1. PEP 695 alias

Valid. The project targets >=3.12, and PEP 695 type statements are already the established form here — four in cuprum/sh.py and two in cuprum/events.py (including ExecPhase, which this module consumes). The legacy assignment was the outlier.

type _MetricOp = _CounterOp | _HistogramOp

I checked the one thing that could have made this unsafe: a type alias produces a lazy TypeAliasType, which breaks runtime uses such as isinstance or a class pattern. _MetricOp appears only in annotations (_exit_operations, _metric_operations, _apply, and one list[_MetricOp] local), and the module has postponed evaluation, so there is no runtime impact. make typecheck is clean.

2. match/case dispatch

Valid, and it restores the idiom used elsewhere — MetricsHook._apply in this same module, and _subprocess_timeout.

One deliberate detail worth flagging. The mapped unit-counter phases stay keyed by _PHASE_COUNTERS behind a guard clause rather than being repeated as a literal alternation:

match phase:
    case "plan":
        return ()
    case "stdin":
        if event.byte_count is None:
            return ()
        return (_CounterOp("cuprum_stdin_bytes_total", float(event.byte_count)),)
    case "exit":
        return _exit_operations(event)
    case _ if (counter_name := _PHASE_COUNTERS.get(phase)) is not None:
        return (_CounterOp(counter_name, 1.0),)
    case _:
        raise _UnhandledMetricsPhaseError(phase)

Writing case "start" | "stdout" | "stderr" | "stdin_error": instead would duplicate the table's key list in the pattern, giving two places to update when a phase is added. The guard keeps the metric names defined exactly once. The guard is also order-safe: none of the table's keys overlap plan, stdin, or exit.

There is history here worth recording. An earlier revision of this function was a match over all eight phases, and was converted to chained ifs because ruff flagged C901 (complexity 9 > 8) and PLR0911 (8 returns > 6). Keeping the table lookup as a single guard clause rather than expanding it into four separate cases holds the structure under both limits — ruff is clean on this form.

plan, the nested stdin byte-count check, exit via _exit_operations, and the unknown-phase _UnhandledMetricsPhaseError all behave exactly as before.

Behaviour preserved, verified rather than assumed. Two mutations of the new dispatch are both caught by the existing suite:

  • dropping the stdin byte-count check → test_stdin_yields_bytes_counter_only_when_counted and the stateful machine fail;
  • returning () for an unknown phase instead of raising → both test_unknown_phase_raises_structured_error tests fail with DID NOT RAISE.

Validation

Command Outcome
uv run pytest cuprum/unittests/test_metrics_adapter.py 13 passed
uv run pytest cuprum/unittests/test_metrics_adapter_stateful.py 9 passed
make check-fmt pass
make lint pass (ruff — no C901/PLR0911 — interrogate 100%, pylint 10.00/10, clippy)
make typecheck pass (ty clean, confirming the lazy alias is fine)
make test pass — Rust nextest 57/57, full Python suite green
make markdownlint / make nixie pass

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai The Linked Issues check is right that the PR is narrower than #78's title, and I have resolved the gap rather than restating the scope.

#78 is titled "Hypothesis stateful tests for metrics/tracing/logging hooks", but its body scopes the work to cuprum/adapters/metrics_adapter.py (around line 206), and its stated verifiability improvement — "extract event-to-operation reducers from side-effecting collector/span calls" — is the metrics reducer this PR extracts. Taking the title at its word, though, the three adapters stand as follows:

Adapter Randomised event-sequence coverage
tracing_adapter.py cuprum/unittests/test_tracing_span_stateful.py — already present before this PR
metrics_adapter.py cuprum/unittests/test_metrics_adapter_stateful.py — added here
logging_adapter.py none

So tracing was already covered and only logging was outstanding. I have raised #252 for it.

Worth noting why that follow-up is not a fourth state machine: RuleBasedStateMachine earns its keep in the other two adapters because each holds an active map keyed by ExecId (TracingHook._active_spans, and the metrics collector's in-flight state), and the interesting bugs are drain and correlation bugs across interleaved sequences. structured_logging_hook has no such state — it maps a phase to a level, builds an extra mapping, and emits exactly one record per event. There is no map to drain, so a state machine would generate interleavings that cannot distinguish any two implementations. #252 specifies @given properties instead, with the cuprum_-prefix collision check and the JsonLoggingFormatter round-trip as the ones that carry real risk.

Keeping this PR to the metrics reducer therefore matches #78's body, and #252 carries the remainder of its title.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@pandalump Record #78 as satisfied for this PR’s metrics-reducer scope. Keep the logging coverage work in #252: @given properties target the meaningful risks for the stateless structured_logging_hook, while state machines remain justified for adapters with ExecId-keyed lifecycle state.

🐇🔍


✏️ Learnings added
Learnt from: pandalump
URL: https://github.com/leynos/cuprum/pull/247

Timestamp: 2026-07-29T13:33:25.349Z
Learning: For cuprum telemetry tests, use Hypothesis RuleBasedStateMachine coverage where adapter behaviour depends on ExecId-keyed active or in-flight state and event interleavings can expose drain or correlation defects (for example, tracing and metrics). The stateless structured_logging_hook should instead use focused given property tests, including cuprum_-prefix collision handling and JsonLoggingFormatter round-trips; this follow-up is tracked in GitHub issue `#252`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@buzzybee-df12

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]

This comment was marked as resolved.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@buzzybee-df12

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

Both outstanding findings are addressed in fe8f8b8. They were posted as
outside diff range comments, so there is no inline thread to reply to;
this is the reply. Acting on the second one turned up a third defect that
neither review caught.

1. One complete phase contract in both documents — taken

Verified first. ExecPhase (cuprum/events.py:20-28) has seven members:
plan, start, stdout, stderr, exit, stdin, stdin_error.
_metric_operations has an arm for every one of them — plan,
stdin, and exit explicitly, and start/stdout/stderr/stdin_error
via _PHASE_COUNTERS. So the reducer is not total over some subset; it is
total over the whole of ExecPhase.

The finding is right that the shared event contract was the weaker
document. §7.1 listed five phases both in prose and in the ExecEvent
sketch, and §8.1.3 said "Cuprum emits plan, start, stdout, stderr,
and exit". The two "documented phase contract" sentences then leant on
that incomplete list.

Rather than scope the wording down to an adapter subset — which would have
been the less true of the two options offered — I named the seven phases
where the event contract is introduced, and used one wording in both
docs/cuprum-design.md and docs/developers-guide.md.

I extended the fix to two places the finding did not cite, because they
were the same defect:

  • docs/cuprum-design.md §7.1 — the source of the incomplete list, both
    the prose bullets and the Literal[...] in the ExecEvent sketch.
  • docs/users-guide.md — the cuprum_phase field listed the same five
    values. That one is worth stating precisely: the structured logging
    adapter's _format_message is fail-open (case _: returns a generic
    cuprum.<phase> message), so it really does emit records with
    cuprum_phase=stdin and cuprum_phase=stdin_error.

The new wording also states the consequence, which is the part that
actually matters: MetricsHook is fail-closed (case _: raise), so a
documented set that lags the handled set is not merely untidy. The two
adapters take opposite stances on an unknown phase, and the docs now say
so.

2. Named exception for the simulated collector failure — taken

Checked what production does with a collector failure before choosing, and
the answer changed the shape of the fix.

Hook exceptions are not isolated. _emit_exec_event
(cuprum/_observability.py:85-99) logs observe_hook_failed and then
raises _ExecEventEmissionError; its sole call site,
_StageObservation.emit (cuprum/_pipeline_types.py:104-107), unwraps
that and re-raises exc.error — the collector's original exception.
test_cqrs_helpers.py:209 already pins this with
pytest.raises(_SyncObserveHookError). Confirmed end to end: a raising
observe hook makes run_sync() raise that hook's own exception type.

So a bare RuntimeError was understating the contract, not just tripping a
lint rule — pytest.raises(RuntimeError) cannot distinguish the injected
failure from an incidental one, and the test stopped at the hook boundary.

  • _FailingHistogramCollector now raises _MetricsBackendError, a
    module-level test exception following the convention already in
    test_cqrs_helpers.py:43-52 (_AsyncObserveHookError,
    _SyncObserveHookError). The injected backend failure stays explicit.
  • The failure-path test asserts that exact type.
  • Added test_a_failing_collector_fails_the_command, which drives a real
    command through a failing collector and asserts the backend's own
    exception type reaches the caller of run_sync unchanged.

Non-vacuity check: replacing raise exc.error from exc in
_pipeline_types.py with a bare return turns the new test red
("DID NOT RAISE"), while the other ten still pass. It pins the contract
rather than restating the hook's internals.

3. A claim the code contradicts — found while verifying (2)

Not in either review. Three places asserted that
_emit_exec_event "catches it, logs observe_hook_failed, and lets the
command continue — a broken metrics backend must not fail the user's
command":

  • cuprum/adapters/metrics_adapter.py (MetricsHook.__call__ docstring)
  • docs/cuprum-design.md §"The exception propagates"
  • docs/developers-guide.md

That is backwards. _emit_exec_event logs and re-raises; the command
dies with the collector's exception. The stateful test's docstring carried
the same error ("which isolates it, so the command survives"). All four are
corrected to describe the actual escalation path, including why the wrapper
exists — _ExecEventEmissionError carries already-scheduled observe tasks
through cleanup, it does not absorb the failure.

git log -S confirms the claim entered with this PR (1c094f2, de3f645)
and is absent from origin/main, so it is ours to fix rather than
inherited.

Bearing on the #243 / #244 counter declines

Both PRs declined an "add metrics counters" recommendation on the grounds
that a new ExecPhase would make the fail-closed MetricsHook raise for
every caller who has already registered it. Nothing here weakens that: this
PR does not make the match tolerant and introduces no facade that would
accept a new phase safely — _metric_operations still ends case _: raise.

If anything the declines were understated. Because the exception is
re-raised rather than swallowed, the consequence of adding a phase without
an arm is not a lost metric but a failed command for every such caller.
Those declines stand, and on firmer ground than was recorded at the time.

Gates

check-fmt, lint, typecheck, test, markdownlint, nixie, and
mbake validate Makefile all green. cs delta origin/main HEAD reports one
finding, a Code Duplication in cuprum/unittests/test_maturin_build.py;
that file is untouched by this commit and unchanged on this branch since
the merge-base (75387d7) — it arrived with the base via #240.

codescene-access[bot]

This comment was marked as outdated.

leynos and others added 11 commits August 3, 2026 22:59
MetricsHook.__call__ was a phase-dispatch that reached straight into the
collector, with no seam to verify which counters and histograms each
event yields across varied phase/order/pid combinations.

Extract the pure event-to-operation reducer _metric_operations(event) ->
tuple[_MetricOp, ...] (with _CounterOp/_HistogramOp records and a
_PHASE_COUNTERS lookup for the unit-counter phases). __call__ now applies
the reducer's operations, resolving labels only when there is at least
one operation, so plan and unknown phases still compute no labels. The
former _increment/_record_stdin_bytes/_record_exit helpers are removed;
behaviour is unchanged and the existing test_metrics_adapter.py suite
still passes.

Add cuprum/unittests/test_metrics_adapter_stateful.py:
- property tests pinning the operations produced per phase (unit
  counters, plan no-op, stdin bytes only when counted, exit failure/
  duration only when present, unknown phase raises);
- a Hypothesis RuleBasedStateMachine that streams random events through a
  real MetricsHook/InMemoryMetrics and checks the accumulated counters
  and histograms against an independent phase-count oracle — proving
  counters and observations are created exactly when intended.

Regenerate the maturin wheel-manifest snapshot for the new test file.

Closes #78

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the seam this branch extracts in design-doc 8.4, where the
telemetry adapter decisions already live.

Adds an "Event-to-operation reduction" subsection stating why the split
exists — the pure _metric_operations reducer decides what to record and
_apply is the only step that reaches the collector, so the mapping is
property-testable without one — plus the two consequences worth pinning:
labels are projected only when the reducer yields an operation, so a plan
event never touches them, and an unrecognized phase raises rather than
being silently dropped.

The sequence diagram carries a screen-reader caption describing the whole
flow in prose, including the empty-tuple early return and which collector
call each operation variant becomes.

The caption is deliberately unnumbered rather than continuing the Figure N
sequence used elsewhere in section 8. PR #245 renumbers the later figures
in that section, so any number chosen here would be wrong under one merge
order; no prose cross-references figure numbers, and section 13 already
uses unnumbered screen-reader captions. Worth a tidying pass once both
land.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings, both valid against current code.

_MetricOp used the legacy assignment form for its union. The project
targets Python 3.12 and already declares aliases with the PEP 695 type
statement in cuprum/sh.py and cuprum/events.py, so this now matches. The
alias is used only in annotations, and the module has postponed
evaluation, so the lazy TypeAliasType introduces no runtime concern.

_metric_operations dispatched through chained top-level ifs. Restore a
match/case on the phase, which is the idiom used elsewhere in this module
(_apply) and in _subprocess_timeout. The mapped unit-counter phases stay
keyed by _PHASE_COUNTERS behind a guard clause rather than being repeated
as a literal alternation in the pattern, so the metric names keep exactly
one definition and cannot drift from the table. plan, the nested stdin
byte-count check, exit via _exit_operations, and the unknown-phase
_UnhandledMetricsPhaseError all behave as before.

An earlier revision of this function was converted away from match to
satisfy the complexity and return-count lints; keeping the table lookup as
a guard clause rather than expanding it into separate cases holds the
structure under both limits, and ruff is clean.

Behaviour is unchanged, verified by mutation rather than assumed: dropping
the stdin byte-count check and silently returning for an unknown phase
each fail the existing property and stateful tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-merge Developer Documentation check noted that no developer-guide
entry covers the new _metric_operations seam; only the design document
records it.

Extend the observability section, beside the existing note that MetricsHook
consumes ExecEvent values, with the split this branch introduces: the pure
reducer decides what to record, _apply is the only step that reaches the
collector, and the stateful test drives random event streams through it
against an independent phase-count oracle. Records the two consequences a
future change must preserve — labels are projected only when an operation
is yielded, and an unrecognized phase raises rather than being dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_PHASE_COUNTERS` is documented as the single definition of these metric
names, but a plain dict leaves that claim unenforced: any importing module
could rewrite an entry and silently redirect a counter. Wrap it in
`types.MappingProxyType` so the mapping matches its stated contract, per
the project's preference for immutable module-level data.

The annotation widens to `cabc.Mapping` accordingly; only `.get` is used
at the call site, so nothing else changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the last gap in #78's title. Tracing already had stateful
coverage before this PR and metrics gained it here, leaving the logging
hook as the only adapter with no randomised event coverage.

Use `@given` properties rather than a fourth state machine, because
`structured_logging_hook` holds no state: it maps a phase to a level,
builds an `extra` mapping, and emits one record per event. A state
machine would generate interleavings that cannot distinguish any two
implementations, since nothing carries between events. Its real risks
are per-event and shape-dependent, and that is what the properties pin:
one record per event, each phase at its configured level, every attached
field `cuprum_`-prefixed so it cannot shadow a reserved `LogRecord`
attribute, a total message formatter, and a JSON round trip.

Two of the five properties were vacuous when first written, which
mutation testing caught rather than review. The JSON property generated
only string tag values, so removing both `_json_serializable` and
`default=str` still passed; `ExecEvent.tags` is typed
`Mapping[str, object]`, so the generator now produces values that are not
JSON-native, which is what those two guards exist for. The level property
accepted any configured level, so dropping a phase from the map and
silently falling back to DEBUG also passed; it now derives the expected
level independently per phase.

All four mutants fail the corrected properties: an unprefixed extra key,
an empty message for an unknown phase, no JSON coercion, and a phase
dropped from the level map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare assert on a shrunk Hypothesis example reports only that two values
differed, which is the least useful moment to lose the phase, the byte
count, or the expected operations.

Attach a message to each, carrying the inputs that produced the failure
and the values on both sides. Verified with an AST walk rather than a
grep: no `ast.Assert` in the module is left without a `msg`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An exit event yields a failure counter and a duration observation as two
independent collector calls, so a collector that raises on the second
records a failure without its duration. That was true but undocumented
and untested, which left it looking like an oversight rather than a
decision.

State the contract: the calls are independent and ordered, no atomicity
is attempted, and what already landed stays. Atomicity is not achievable
here — the collector wraps an arbitrary backend, and buffering to apply
together would only move the problem while delaying when metrics appear.
Note where the exception goes: `_emit_exec_event` catches it, logs
`observe_hook_failed`, and lets the command continue, because a broken
metrics backend must not fail the user's command.

Pin it with a collector whose histogram writes fail, asserting the
counter remains, the observation does not, and the error reaches the
caller. Verified by mutation: reversing the operation order fails it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three review findings on the metrics adapter tests.

`emit` retyped the four phase-to-counter pairs a third time, after the
production `_PHASE_COUNTERS` and the module's own
`_UNIT_COUNTER_PHASES`. Key the existing list once and look up through
it, so the stateful oracle and the parametrized cases cannot drift.
It stays a test-local restatement rather than an import of the adapter's
table: an oracle reading the production mapping would agree with it by
construction and could not catch a wrong metric name.

Dispatch the phases with `match`/`case`, matching the reducer's own
style, and give the fall-through an explicit arm — `plan` and an
uncounted `stdin` both leave the oracle unchanged, which was previously
only implied by the absence of a branch.

Caption the metrics-dispatch diagram, which was the only one in the file
without one. The number is provisional; `#251` tracks renumbering.

Document the non-atomic application contract outside the docstring. An
`exit` event applies two independent collector calls in a fixed order, so
a collector that raises on the second leaves the first applied — which is
something a collector implementer needs before writing one, not something
to discover from a source docstring. Add the screen-reader description
the figure was also missing, and a pointer from the developers' guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dispatch contract said a collector should treat each call as
"independent and idempotent-safe". The second half is unsupported:
`inc_counter` and `observe_histogram` receive no event or operation
identifier, so a collector has nothing to deduplicate on and a repeated
call increments again.

Say what is actually true instead — calls are independent and ordered —
and state the absent guarantee explicitly rather than leaving it
inferred. The adapter never retries a failed call either, which is why a
partial application stays partial; a collector wanting exactly-once has
to get the identity from somewhere else.

Corrected in all three places the contract is stated: the design
document, the `MetricsHook` docstring, and the developers' guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two documentation claims about the metrics reducer were incomplete or
wrong, and one test asserted the weaker of two available contracts.

**The phase contract was under-stated.** `ExecPhase` has seven members,
and `_metric_operations` has an arm for every one of them, but the
shared event contract listed only five — omitting `stdin` and
`stdin_error`. Both documents then leant on "the documented phase
contract" to describe a reducer that is in fact total over the whole of
`ExecPhase`. Name the seven phases where the event contract is
introduced, and use one wording in both the design document and the
developers' guide. The users' guide's `cuprum_phase` value list had the
same five-phase gap; the structured logging adapter is fail-open, so it
really does emit those records.

**The escalation path was documented backwards.** Three places claimed
`_emit_exec_event` "lets the command continue", so that a broken metrics
backend cannot fail a user's command. It does not. It logs
`observe_hook_failed`, wraps the error in `_ExecEventEmissionError` to
carry already-scheduled observe tasks through cleanup, and
`_StageObservation.emit` unwraps that and re-raises the collector's
original exception — the command dies with it. State that instead, and
say what follows: because the reducer's phase match is fail-closed,
adding an `ExecPhase` value without an arm would raise for every caller
that has already registered `MetricsHook`.

**The failure-path test now asserts that.** The simulated backend
failure was a bare `RuntimeError`, which `pytest.raises` cannot
distinguish from an incidental one. Give it a named
`_MetricsBackendError`, following the test-local exception convention in
`test_cqrs_helpers.py`, and add a behavioural case driving a real
command through a failing collector: the backend's own exception type
reaches the caller of `run_sync` unchanged. Removing the re-raise in
`_pipeline_types.py` turns that case red, so it pins the contract rather
than restating the hook's internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lodyai
lodyai Bot force-pushed the python-metrics-adapter-tests branch from fe8f8b8 to 1bb01ad Compare August 3, 2026 20:59
codescene-access[bot]

This comment was marked as outdated.

@codescene-access codescene-access Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No quality gates enabled for this code.

@leynos

leynos commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Have the following failed checks now been resolved?

If further work is required, please provide an AI agent prompt for the remaining work to be done to address these failures.

Do not treat warnings as optional or aspirational. Where a change is out of scope for this PR, propose a GitHub issue unless one exists already. (Treat o11y, code safety, documentation and validation coverage as in scope).

#78 Add similar Hypothesis stateful tests for tracing and logging hooks (including proving active maps drain correctly) as referenced in the issue title. ❌ The PR explicitly scopes its work to the metrics adapter only, adding reducer extraction and stateful tests for MetricsHook and InMemoryMetrics. It does not modify or add tests for tracing or logging hooks, nor does it address active map draining.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Issue `#78` =='
gh issue view 78 --repo leynos/cuprum --json number,state,title,body,url \
  --jq '{number,state,title,url,body}' || true

printf '%s\n' '== Relevant test files =='
fd -a -t f '^(test_metrics_adapter_stateful|test_tracing_span_stateful|test_logging_adapter_properties)\.py$' cuprum/unittests

printf '%s\n' '== Relevant symbol outlines =='
for file in \
  cuprum/adapters/metrics_adapter.py \
  cuprum/adapters/tracing_adapter.py \
  cuprum/adapters/logging_adapter.py \
  cuprum/unittests/test_metrics_adapter_stateful.py \
  cuprum/unittests/test_tracing_span_stateful.py \
  cuprum/unittests/test_logging_adapter_properties.py
do
  if [ -f "$file" ]; then
    echo "--- $file"
    ast-grep outline "$file" --items all || true
  fi
done

printf '%s\n' '== Direct coverage evidence =='
rg -n -C 3 \
  'RuleBasedStateMachine|_active_spans|active\.keys|_active_spans\.keys|assert .*not in .*_active_spans|`@given`|structured_logging_hook|JsonLoggingFormatter|cuprum_' \
  cuprum/adapters/tracing_adapter.py \
  cuprum/unittests/test_metrics_adapter_stateful.py \
  cuprum/unittests/test_tracing_span_stateful.py \
  cuprum/unittests/test_logging_adapter_properties.py \
  || true

Length of output: 37274


@leynos The failed #78 check is resolved.

Verify these completed areas:

  • cuprum/unittests/test_metrics_adapter_stateful.py tests MetricsHook and InMemoryMetrics with a Hypothesis RuleBasedStateMachine. It checks counters and histograms against an independent oracle.
  • cuprum/unittests/test_tracing_span_stateful.py tests interleaved events for two ExecId values that share one PID. It verifies that TracingHook._active_spans matches an independent model after every step. It also verifies that exit removes the matching active span.
  • cuprum/unittests/test_logging_adapter_properties.py adds five Hypothesis @given properties for structured_logging_hook. They verify one record per event, cuprum_-prefixed extra fields, JSON formatting with non-native tag values, non-empty messages, and correct configured levels.

Do not add a logging state machine. structured_logging_hook has no active map or cross-event lifecycle state. Its property tests cover the relevant per-event risks.

Do not open a follow-up issue. No remaining work exists for this check.

Close #78. The issue remains open, but the current PR satisfies its stated coverage and active-map-draining requirements.

You are interacting with an AI system.

@leynos
leynos merged commit 143d19c into main Aug 3, 2026
20 checks passed
@leynos
leynos deleted the python-metrics-adapter-tests branch August 3, 2026 23:21
lodyai Bot pushed a commit that referenced this pull request Aug 4, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
lodyai Bot pushed a commit that referenced this pull request Aug 4, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
lodyai Bot pushed a commit that referenced this pull request Aug 6, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
leynos added a commit that referenced this pull request Aug 24, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
leynos added a commit that referenced this pull request Aug 24, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
leynos added a commit that referenced this pull request Aug 25, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
leynos added a commit that referenced this pull request Aug 25, 2026
The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.
leynos added a commit that referenced this pull request Aug 25, 2026
… telemetry (#73, #285) (#243)

* Extract pure completion transition; state-machine test it (#73)

Pipeline completion ordering lived inline in _process_completed_task,
which mutated _PipelineWaitState, read the clock, and terminated stages
in one async method — untestable at the state-transition level.

Extract the pure decision into _PipelineWaitState.record_completion(
completed_idx, exit_code, *, ended_at): it stamps the exit code and end
time (clock injected), latches the first non-zero exit in completion
order as failure_index, and returns whether the remaining downstream
stages must be terminated. _process_completed_task now calls it and keeps
the clock read and the async termination side effect.

Add cuprum/unittests/test_pipeline_wait.py: a Hypothesis
RuleBasedStateMachine drives random completion orders (restarting fresh
pipelines when drained) and asserts first-failure semantics, timing-slot
population, and the termination decision; example tests pin the
boundaries (first-completed failure wins, final-stage failure requests no
termination, all-success records no failure, fail-fast fires once).

Regenerate the maturin wheel-manifest snapshot for the new test file.

Closes #73

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Segregate the completion command from the fail-fast query

Review findings on the completion transition:

- record_completion both mutated the wait state and returned the
  termination decision, so a query was inseparable from a command,
  against the command/query segregation rule in AGENTS.md. Split it:
  record_completion is now a command returning None, and the new
  should_terminate_others(completed_idx) query reports the fail-fast
  decision from state without changing it, so it is repeatable and
  order-independent. _process_completed_task calls the command, then the
  query.
- AGENTS.md requires function documentation to demonstrate usage and
  outcome; both methods gained worked examples.
- The docstring described terminating the "remaining downstream stages".
  _terminate_pipeline_remaining_stages stops every still-running stage
  except the failed one, upstream included, so the wording now says
  "every other still-running stage".
- The suite only covered the pure transition, so it could not catch
  inverted or omitted termination, or a mis-forwarded clock or index. Add
  TestProcessCompletedTask, which drives the real async boundary with a
  recording termination double and an injected perf_counter. Verified
  non-vacuous: omitting the termination call and forwarding the wrong
  clock each fail these tests.
- Every bare assertion in the suite gained a diagnostic message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Document the pipeline completion-ordering seam

The pre-merge Developer Documentation check flagged that the new
record_completion / should_terminate_others seam was described only in
docstrings, with the design doc covering fail-fast behaviour generally.

Add a "Completion ordering seam" subsection to the fail-fast policy in the
design doc: what each half of the command/query split does, that the clock
is injected so the transition is deterministic, that completion order
rather than stage order decides which failure latches, and that
_process_completed_task is the sole caller joining the two.

Also correct the adjacent policy wording, which said fail-fast terminates
"the remaining stages": it terminates every other still-running stage,
upstream included. That is the same inaccuracy already corrected in the
docstrings on this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Verify the completion transition with CrossHair; observe fail-fast

Completes issue #73's CrossHair criterion and the two unresolved warnings
on this PR, without weakening the command/query split.

CrossHair (test_pipeline_wait_crosshair.py): PEP 316 contracts confirm,
over a bounded symbolic model, that a completion writes only its own slot,
that the first non-zero completion latches failure_index and a later one
never replaces it, that should_terminate_others holds exactly for a
non-final first failure (covering final-stage and single-stage
pipelines), and that repeating the query changes nothing. The model caps
the pipeline at three stages, exit codes at -2..2, and timestamps at
0.0..4.0, and builds the state directly, so no asyncio task, subprocess,
or clock enters the symbolic space. Verified genuine rather than assumed:
dropping the final-stage exclusion and letting a later failure re-latch
each yield POST_FAIL instead of CONFIRMED.

The import-time probe follows test_line_splitting.py and degrades to a
skip only for a missing dependency (ImportError) or a tracer that cannot
handle the interpreter (TraceException); every other failure is re-raised,
so a supported interpreter runs the verification rather than warning past
it. That probe logic existed already, so per the AGENTS.md abstraction
policy it moves to the shared cuprum/unittests/_crosshair_support.py that
both modules import, instead of being duplicated. test_line_splitting.py's
eleven harness tests cover it unchanged.

Observability: _process_completed_task now emits two structured records
through logging.getLogger(__name__) — pipeline_stage_first_failure when a
completion newly latches, and pipeline_fail_fast_termination immediately
before termination is awaited — sharing cuprum_stage_index,
cuprum_exit_code, and cuprum_duration_s, and distinguished by a stable
cuprum_action. Neither fires for a success, a later failure, or a
final-stage or single-stage failure. Logging stays in the async caller;
moving it into the command or query would break the determinism the
symbolic verification depends on. Five log-capture tests pin those cases,
and are non-vacuous: logging unconditionally, or dropping the termination
record, each fail them.

Documents the seam, the record fields, and both verification commands in
the developers guide, with a matching note in the design doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Group completion pairs to clear the argument-count finding

CodeScene flagged Excess Number of Function Arguments on both
_latches_first_failure and _first_failure_latch_contract, each taking five
scalars. Replace the parallel index/exit-code arguments with one
(stage_index, exit_code) tuple per completion, so both take three:
stage_count plus the two completion pairs, in completion order.

Grouping per completion rather than per kind keeps the two values that
describe a single event together, which is what the arguments already
meant. The helper unpacks both tuples immediately, leaving the state
construction, the ordered record_completion calls with their 1.0 and 2.0
timestamps, and the latch assertions untouched.

The symbolic domain is preserved exactly: the contract's preconditions now
bound each tuple element individually — two distinct valid stage indexes
and two exit codes in -2..2 — rather than the former scalars. Plain typed
tuples keep CrossHair's inputs primitive and bounded; no dataclass,
NamedTuple, or runtime construction is introduced.

Verified the refactor did not weaken the exploration rather than assuming
it: letting a later failure re-latch, and latching a hard-coded index,
each still yield POST_FAIL instead of CONFIRMED. CodeScene health for the
file goes from 9.68 to 10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Document the fail-fast records in the users guide

The two records this PR adds are emitted at WARNING on a default logging
configuration, so users see them whether or not they went looking. They
were documented only in the developers guide, which is the wrong audience
for output that arrives unbidden.

Add a fail-fast diagnosis section under pipeline execution covering both
records, their shared fields, and the two behaviours that otherwise read as
bugs: only the first failure is reported, because fail-fast makes the
remaining stages fail too and reporting each would bury the cause; and a
final-stage failure emits no termination record because nothing was left
running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Say which stages fail-fast terminates

The users guide said fail-fast terminates "the remaining stages", which
reads as the stages after the failure. It terminates every *other*
still-running stage: `_stages_to_terminate` selects every index that is
neither the failed stage nor already settled, so an upstream producer is
stopped just as a downstream consumer is.

`should_terminate_others` and the design document already say this
explicitly; the guide was the one place left implying downstream-only,
which matters because a user reasoning about a slow upstream producer
would draw the wrong conclusion.

Correct the pre-existing bullet as well as the new table row, so the two
statements a few lines apart cannot disagree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Describe the completion seam in the module docstring

"Pipeline waiting logic with fail-fast semantics" named the subject but
not the structure, so a reader had to reconstruct the command/query split
from the code and could not tell which neighbouring module owns what.

State the seam: the command latches the first failure in completion
order, the query reports whether to stop the others, and
`_process_completed_task` is the only place the two are joined. Name what
deliberately lives elsewhere — process termination and cleanup in
`_process_lifecycle`, pipe-task collection in `_pipeline_streams` — so the
boundary is explicit rather than inferred from the imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Split the pipeline-wait tests by concern

`test_pipeline_wait.py` had reached 539 lines, past the 400-line cap, and
mixed four distinct concerns: a Hypothesis state machine, pinned boundary
cases, the async wiring, and the structured records.

Split it along those lines into four modules, each named for what it
covers and none above 191 lines. The state machine and the examples now
say in their docstrings how they relate — one generalises what the other
pins — which the single file left implicit.

Extract the shared scaffolding into `_pipeline_wait_support.py`. The two
`fake_terminate` doubles were near-identical but had drifted: one recorded
`(index, cancel_grace)` pairs, the other only indices. `record_terminations`
keeps the richer form, so a test that needs the grace period no longer has
to reintroduce its own copy to get it.

Update the developers guide, which pointed at the single file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make simultaneous completions deterministic, and pin the clamp

Four review findings on the pipeline-wait observability work.

`_wait_for_pipeline` iterated the set `asyncio.wait` returns, so stages
completing in the same batch had no completion order left to observe and
`failure_index` fell out of set iteration — the module docstring's "first
non-zero exit in completion order" was a claim the code could not keep.
Sort each batch by stage index. That is both deterministic and the more
useful answer: in a pipeline the upstream failure is what caused the
downstream ones it triggered, so it is the one worth naming.

Add the two tests that pin it, driving real settled stages through
`_wait_for_pipeline`. Recording the processing order rather than only the
outcome makes the check deterministic instead of relying on set order
happening to differ from stage order. Mutation-verified: reverting to the
bare set fails the ordering test on every run.

Add the duration-clamp case. Every existing observability test starts a
stage at zero and reads a later clock, so all of them hold whether or not
`max(0.0, ...)` is there; this one inverts the pair and would publish
-87.5 seconds without it. Mutation-verified.

Add a syrupy snapshot over the whole record shape — logger, level,
rendered message, and every `cuprum_` field. The field assertions each
cover the part their test is about; the snapshot catches a change to any
part no test happened to assert on.

Derive the log fields only when a record will carry them. Every stage
passes through this function and most emit nothing, so a success no
longer allocates fields it discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Pin completion order at the async boundary, not just in the transition

Every case in `TestProcessCompletedTask` drove a single completion, so
none of them could tell completion order from stage order: a boundary
that latched the lowest-indexed failure instead of the first one to
complete passed all three. The Hypothesis state machine does catch that
mutant, but only against the pure transition — it says nothing about the
index `_process_completed_task` actually passes to `record_completion`.

Add a two-completion case where stage 2 fails before stage 0, and a
`_run_completions` driver to sequence it. The clock advances per
completion rather than staying pinned, so each stage's `ended_at` is
distinguishable and a boundary that reused an earlier reading is
visible.

This is the counterpart to `TestSimultaneousCompletions`: stage order is
the tie-break *within* one `asyncio.wait` batch, where completion order
cannot be observed, and completion order decides across batches. Neither
test alone pins that boundary.

Mutation-verified three ways — latching the lowest index, terminating on
every non-final failure, and reusing an earlier end time each fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say which stage wins when a batch settles together

The guide and the design record described completion order as the sole rule
for latching `failure_index`, but `_wait_for_pipeline` also sorts each
`asyncio.wait` batch by stage index. That sort was undocumented outside the
module, so a reader could not tell whether it encoded a priority or merely
made an unobservable order deterministic.

Document it as the tie-break it is: real completion order decides across
batches, and ascending stage index only orders stages *within* one batch,
where `asyncio.wait` returns an unordered set and the alternative is a
`failure_index` that varies run to run. Record why the tie is broken towards
the earliest stage — an upstream failure causes the downstream ones it
triggers — and name the test that would catch the sort's removal, since tests
that fail stages at distinct times cannot.

Align roadmap 2.1.2 with the same wording: it said "terminate downstream"
where the implementation terminates every other still-running stage, upstream
producers included.

* Let a fail-fast record say which run it came from

The fail-fast records carried a stage index, an exit code, and a duration.
That describes a failure but does not locate it: two pipelines running
concurrently in one process both report a stage 0, and nothing joined a record
to the span or lifecycle events the observe hooks publish for that same stage.

Carry the stage's existing `ExecId` as `cuprum_exec_id`, plus
`cuprum_stage_count` so a record is self-describing. The token reaches the
wait path as `_PipelineSpawnResult.stages`, a `_StageWaitContext` grouping the
per-stage data the wait already needed, so `started_at` and the tokens travel
together rather than as parallel arguments. `_PipelineWaitState` defaults it
to empty and reports `None`, because the pure transition never reads it and
the symbolic model must not carry it.

Report the teardown as well as its start. `pipeline_fail_fast_termination` is
emitted *before* termination is awaited, so on its own it cannot distinguish a
teardown that finished from one still blocked on a stage that will not die. A
new `pipeline_fail_fast_terminated` record closes that gap once termination
returns, carrying how many stages were stopped —
`_terminate_pipeline_remaining_stages` now returns that count rather than
having the caller recompute the selection — and how long it took.

Move the record payload into `cuprum/_pipeline_wait_records.py`: it is a
published contract the users' guide documents, and keeping it beside the
verified ordering decision invited editing one while meaning the other. The
logger name is hard-coded there rather than following `__name__`, because
operators are told to attach handlers to `cuprum._pipeline_wait`.

No metric and no trace event. The library emits neither on its own —
`MetricsHook` is an opt-in adapter over `ExecEvent`, and its phase match is
fail-closed, so a fail-fast phase would make it raise for every user who has
already registered it. Both would need a new observability surface rather than
a use of an existing one; `cuprum_action` is a stable low-cardinality event
name a log-based counter can key on meanwhile.

* Tell the hooks when a pipeline gives up on itself

A fail-fast teardown was, until now, reported only as log records on
`cuprum._pipeline_wait`. That is fine for a human reading logs and
useless for the metrics and tracing integrations, which would have had
to parse log text to notice that a pipeline had been torn down early.

Add a `pipeline_fail_fast` `ExecPhase`, emitted once per pipeline for
the completion that *newly latches* `failure_index` and still leaves
stages to stop — so not for a success, not for a failure that follows
the latch, and not for a final-stage or single-stage failure, none of
which trigger a teardown. It is published *before* termination is
requested, so a consumer sees the decision even when the teardown then
blocks on a stage that will not die.

The event reuses the failing stage's existing `exec_id`. That is what
lets a trace show the teardown on the span the stage's own `start`
opened, and it is why `_StageWaitContext` now carries the stage
observations alongside the tokens: publishing an `ExecEvent` needs the
stage's program, argv, and tags, not merely its token. Both are derived
from one observation tuple at the single construction site, so a log
record and the matching event cannot disagree about which stage failed.
`record_completion` and `should_terminate_others` stay a pure command
and query; emission stays at the async boundary.

`stage_index` and `stage_count` are typed fields rather than tags
because caller-supplied tags are merged last and may legitimately
shadow `pipeline_stage_index`; the decision has to report the index the
coordinator acted on.

The adapters render it three ways: `MetricsHook` increments
`cuprum_pipeline_fail_fast_total`, labelled by `program` and `project`
and nothing else — `exec_id` would make the series unbounded, and stage
index and exit code would multiply it for no aggregate a dashboard
needs; `TracingHook` adds a `cuprum.pipeline_fail_fast` span event to
the failing stage's open span, starting and ending none; the logging
adapter renders it at `LogLevels.fail_fast_level`, WARNING by default,
rather than falling through to a generic DEBUG message that reports
only the program.

Adding a phase is a contract change, recorded as such in the changelog.
`MetricsHook`'s match is fail-closed and hook failures are re-raised, so
a hook that rejects unknown phases will raise until updated. In this
repository that is one adapter, updated here; `TracingHook` and the
structured logging hook are both fail-open. Third-party fail-closed
hooks need an explicit arm, which is a versioning consideration rather
than a reason not to emit.

The `Span` and `Tracer` protocols move to `tracing_protocols.py`, re-
exported unchanged. This is a precondition rather than a drive-by: the
hook module was at the 400-line ceiling, and the contract reads better
without the correlation machinery around it.

* Give the silent-completion cases a name each

The parametrized test took its stage count, completion sequence, and
reason as three separate arguments, which CodeScene flags as excess
arguments and which reads as a bare tuple at the call site. Wrap them in
a `_SilentCase` record, matching the `_RecordCase` pattern the sibling
observability module already uses for the same shape.

Behaviour is unchanged; all three cases still run and still fail when
the emission condition is widened to cover final-stage or single-stage
failures.

* Teach the merged oracles about the eighth phase

The rebase onto main brought in the metrics reducer and the two
adapter property suites from #247, which enumerate `ExecPhase` and the
counter table independently of production so a wrong name cannot pass
by construction. Those enumerations stopped at `exit` and so never saw
`pipeline_fail_fast`, leaving the new arm and its counter unexercised
by the very oracles written to catch a bad mapping.

Widen both enumerations and give the logging level property its own
`fail_fast_level` expectation, sampled rather than defaulted so the
mapping is checked and not merely observed. Adding the phase to the
level suite failed until the expected-level map gained an arm, which is
the point of deriving it independently.

Also reconcile the documented lists the two branches each half-updated:
the metrics counter set, the tracing drop list, the configurable log
levels, and the `cuprum_phase` values.

* Say what fail-fast really terminates, and who projects it

Three published descriptions of the fail-fast telemetry disagreed with the
code they describe.

The termination scope was written as "the surviving stages", which reads as
the stages downstream of the failure. `should_terminate_others` stops *every
other still-running stage*, upstream producers included, so all five prose
sites now use that one phrase.

The design document still claimed fail-fast emits neither a metric nor a
trace event and that both would need a new observability surface. Section 8.4
of the same document has documented the opposite since the adapters landed:
the wait path publishes records and one event, and `MetricsHook` and
`TracingHook` project that event onto the counter and the failing stage's
open span.

The users' guide claimed the metrics adapter publishes `exec_id`. It does
not — `cuprum_pipeline_fail_fast_total` is labelled by `program` and
`project` alone — so a counter spike is joined to a stage through the
matching log record or event, never through a label. Its tracing drop list
also omitted `stdin_error`, which `TracingHook` drops like the rest.

* Pin the exit race, and drop a suppression instead of moving it

`Span` and `Tracer` each carried a `# pylint: disable=unnecessary-ellipsis`,
which the project's suppression policy does not allow. The rule is enabled
deliberately and it is the docstring-plus-ellipsis pair it objects to, so the
suppression could not simply be narrowed. `MetricsCollector` — the sibling
adapter protocol, next door — has always written its stubs as
`raise NotImplementedError`. The tracing protocols now do the same, and the
suppressions are gone rather than relocated.

`_handle_exit` pops a span under the lifecycle lock and ends it outside, so
there is a window where the span object exists, is not yet ended, and is no
longer reachable through the active map. Two tests now park a `Span.end` to
hold that window open and assert what must happen inside it: a
`pipeline_fail_fast` for the same execution finds nothing and is dropped, as
any uncorrelatable event is; and one for a *different* execution is recorded
without waiting on the parked end.

Both bite. Ending the span before detaching it records the fail-fast on a
span the backend has already closed; holding the lock across `end()` stalls
the unrelated fail-fast until the backend returns.

* Give the clock its own seam, and the helpers their prefix

`_pipeline_wait_records` exports three helpers into one private module, and
all three read as public API. Their neighbours across the wait path —
`_terminate_pipeline_remaining_stages`, `_collect_pipe_results`,
`_stages_to_terminate` — and the dataclass in this very file are all
prefixed. These now are too.

Pinning the clock reached through `_pipeline_wait.time`, which is the stdlib
`time` module object, so every test that pinned it replaced
`time.perf_counter` for the whole process while it ran. The wait module now
binds `perf_counter` itself, which gives the tests an attribute of their own
to patch. The advancing variant that `test_pipeline_wait_async` had inline
moves to the support module as `AdvancingClock`, so both clock helpers patch
the one seam and a future change to it lands in one place.

* Stop the tests grading their own homework

`test_the_helper_counts_the_stages_it_stopped` computed its expectation from
`_stages_to_terminate`, which is the very selection the helper it checks
derives its targets from. Any change that moved both together passed. The
counts are now stated per case, and a case where the failed stage's wait task
is still running has been added — without it, sparing the failed stage and
sparing an already-settled stage are indistinguishable.

`test_first_nonzero_exit_latches_failure_index` claimed to pin a lower-indexed
stage failing after a higher-indexed one, but had stage 2 *succeed* first, so
a rule that latched the lowest failing index agreed with it throughout. Stage
2 now fails first, and stage 0's later, lower-indexed failure must not
displace it.

Two modules hand-rolled the create-task/settle/assign-index/call sequence that
`apply_completions` already provides, and a third copy of the
select-one-`cuprum_`-field comprehension sat in the correlation module. Both
now come from the support module, so a change to the boundary signature is one
edit rather than three.

`drive_completions` documented a `completions` parameter it does not take, and
the CrossHair module pointed at a `test_pipeline_wait.py` that does not exist.

* Say what fail-fast really terminates, and what a raising hook does

Two user-facing claims did not match the code they describe.

The pipeline notes said a stage exiting non-zero terminates every other
still-running stage. The coordinator is narrower than that: only the
*first* failure latches, and `should_terminate_others` answers `True`
only when that failure is also not the final stage. A failing final
stage, and any single-stage pipeline, terminate nothing — there is
nothing left running to stop — and later failures are usually
consequences of the first rather than new causes. The design document's
decision list carried the same unqualified claim, phrased as "the
remaining stages"; it now uses the wording the other five prose sites
settled on.

The guide also said nothing about what happens when an observe hook
raises, which matters now that `pipeline_fail_fast` is a new phase a
fail-closed hook will reject. Cuprum does not swallow the failure: it
logs, then re-raises the hook's own exception type out of `run()` /
`run_sync()`. The two hook kinds differ only in when. A synchronous
hook raises inline, so emission of that event stops and later hooks do
not see it; an awaitable hook raises inside its scheduled task, so
every hook still receives the event and the failure surfaces at the
drain before the run returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop the tests restating what the helpers already say

Four review points, none of which change behaviour:

- `_UNIT_COUNTER_PHASES` was a plain list beside a `MappingProxyType`
  sibling. Make it a tuple so neither of the two views a test can take
  of the same pairs is mutable.
- The fail-fast wiring tests ran three real subprocesses each, three
  times over, for one pipeline's worth of events. `scoped` is entered
  and left inside the helper and the tests only read what it returned,
  so a module-scoped fixture serves all three. It returns a tuple, so
  no test can hand the next one a shortened sequence.
- Two assertions re-implemented the `vars(record)` comprehension that
  `field_values` and `record_actions` already provide. Use them.
- Drop the `hook._active_spans == {}` assertion. It reaches into
  private state to restate what the two public assertions above it
  already pin: no event recorded, and the span ended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Do not announce a teardown that has nothing to tear down

`should_terminate_others` reasons from stage positions alone, so it
answers `True` for any non-final latched failure — including one whose
sibling stages have all already exited. That happens whenever a whole
pipeline settles into a single `asyncio.wait` batch: the batch is
handled in stage order, so an upstream failure can be reached after
every other stage is done. The fail-fast event, both termination
records, and therefore `cuprum_pipeline_fail_fast_total` all fired for
that case, reporting a termination decision with no subject and a
closing `cuprum_terminated_stage_count` of zero that contradicted the
record which opened it.

`_process_completed_task` now asks `_has_stages_to_terminate` — which
wraps the same `_stages_to_terminate` reducer the teardown picks its
targets with, so the announcement and the teardown cannot disagree —
and withholds the event, both records, and the termination call when
nothing is left running. `pipeline_stage_first_failure` still fires:
the failure latched, and `failure_index` reports it either way; what is
absent is the termination decision, not the failure.

`record_completion` and `should_terminate_others` stay pure, and the
event still precedes the termination request.

Two supporting changes the above needed:

- `_StageWaitContext` was frozen but held `started_at` as a list, which
  `_PipelineWaitState.from_processes` then aliased — the "immutable
  snapshot" and the live bookkeeping were one object. The field is now
  a tuple and the wait state copies it.
- `apply_completions` gave the state a single wait task, so every
  sibling read as settled. It now builds one task per stage and swaps
  in a settled one per completion, as `_wait_for_pipeline` does, and
  takes a `before_each` hook so the clock-advancing driver in
  `test_pipeline_wait_async` can use it instead of duplicating it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

The docs record the new case alongside three wording fixes from the
same review round: the design doc's error-propagation policy is no
longer hedged as provisional, `failure_index` is described as the stage
that failed first rather than the one that triggered fail-fast, and the
determinism claim is scoped to stages settling in one wait batch.

* Say which failures have nothing left to stop

The developers' guide listed the completions that emit no termination
record as "a final-stage or single-stage failure". That set grew when
`_process_completed_task` started checking whether any sibling stage was
still running: a batch is handled in stage order, so an upstream failure
can be reached after every other stage has exited, and that case is now
silent too. Name the real condition, point at the reducer that decides
it, and say that the first-failure record is exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Leave the fail-fast path once there is no teardown

Adding the live-stage check pushed `_process_completed_task` to a
cyclomatic complexity of 9, on CodeScene's threshold, and dropped the
module's code health to 9.68.

The two trailing branches both asked whether termination was happening,
so return once instead. Everything after that point is the teardown, and
the latch is re-tested on its own rather than paired with
`terminate_others` in a compound condition. Same behaviour, two fewer
decision points, code health back to 10.00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say what the lifecycle module owns, once

`_process_lifecycle` carried a one-line docstring over 397 lines that
spawn processes, tear them down on two separate routes, and shield that
teardown from caller cancellation. None of that was stated where a
reader arrives.

Give the module a docstring covering spawn and cleanup-on-error, the
fail-fast selection trio, the timed-out teardown, the shielding
rationale, and the division of labour with `_pipeline_wait`, which
decides while this module executes.

The shielding rationale was previously repeated verbatim in three helper
docstrings. Now that the module owns it, condense those three to the
detail local to each, keeping the file within the 400-line ceiling.

Also clarify in the users' guide that the fixed `WARNING` of the
Table-1 records is not the configurable `fail_fast_level`, which belongs
to the opt-in adapter, and note in the security note that
`pipeline_fail_fast` inherits the same `cuprum_argv` projection as every
other phase.

* Make _BatchRun immutable in fact, not just in name

`_BatchRun` is `frozen=True`, which stops a field being rebound but not
the four lists behind those fields being appended to. A holder of the
record could still change what the run reported.

Hold tuples instead, snapshotting the collectors at construction, once
the run that fills them is over. `field_values` and `record_actions`
take a `Sequence` so they still accept both the tuples and the raw
`caplog.records` list.

* Say when the fail-fast event stays silent

The release note and the normative event contract both described
`pipeline_fail_fast` as firing whenever a non-final stage is the first to
fail. That is only half the rule: the emission is gated on there being
another stage still running. A non-final failure processed after every
sibling has already settled still latches `failure_index`, but emits no
event and no termination records, because there is nothing left to stop.

Both documents now state that condition, so a reader building a metrics or
tracing integration against them is not surprised by a run that fails
fast-ish and publishes nothing.

* Hold teardown against every cancellation

`_await_teardown_shielded` shielded the first await and then re-awaited the
teardown bare. A shield protects only the await it wraps, so the retry was
unprotected: a second cancellation aimed at the caller landed on the
teardown itself, cancelling the SIGTERM/grace/SIGKILL escalation mid-flight.
The `suppress` around it then swallowed the evidence, and the docstring's
claim that teardown "completes regardless" was false — a SIGTERM-immune
stage could outlive the run that spawned it.

The wait now loops on a fresh `asyncio.shield` until the teardown future is
actually done, recording each cancellation and re-raising the first, so the
caller still sees exactly one. `cuprum.sh._execute_with_hooks` keeps its
single shield: it drains bookkeeping tasks rather than owning process
lifetimes, so a second cancellation there orphans nothing.

The new case in `test_pipeline_teardown_cancellation` cancels the waiter
twice inside an event-coordinated grace window and asserts the stage was
still reaped; it also asserts the escalation had not yet run when the second
cancellation landed, so it cannot pass by racing the clock. Restoring the
unshielded retry fails it.

The cancellation cases move out of `test_pipeline_timeouts` into that new
module: adding them there would have pushed the file past the 400-line cap,
and "what the caller sees when a deadline expires" and "what survives when
the caller cancels mid-teardown" are two concerns anyway.

* Spell test prose the way the house spells it

`test_adapter_fail_fast` and `test_tracing_span_concurrency` used the -ise
forms in module docstrings, method docstrings, and comments, which the
en-GB-oxendict house rule spells -ize; `test_pipeline_wait_state_machine`
had the same in "initialises".

`test_an_unrecognised_phase_still_raises` is renamed with them. The rule
that preserves existing -ise identifiers exists to protect names other code
depends on, and this one has no consumers at all: it is a test method, named
nowhere but its own definition. Leaving it would plant a fresh counterexample
just as PR #259 lands the identifier policy.

* Keep downstream stages alive past the failing one

The wiring test's three stages could all settle before the waiter was
resumed. `asyncio.wait(..., FIRST_COMPLETED)` returns every task that
finished, not just the first, so one batch could hold all three: stage 0 is
then processed after its siblings, the settled-batch gate this PR added
finds nothing left to terminate, and the run emits no `pipeline_fail_fast`
event. The fixture's `assert len(fail_fast) == 1` would fail, on a run that
behaved exactly as specified.

The downstream stages now sleep briefly once their stdin closes, which puts
them in a later batch than the failing stage. The fixture docstring records
why, so the delay is not read as an arbitrary sleep and removed. It is
module-scoped, so it is paid once.

* Give the remaining frozen test cases tuple fields

`_Driven`, `_SilentCase`, and `_RecordCase` are `frozen=True`, which blocks
rebinding a field but not mutating a list held in one. `_BatchRun` was moved
to tuples earlier in this PR for that reason; these three were left behind.

Their list fields become tuples, `_drive` snapshots both collectors as it
builds its result, and the assertions compare against `()` and a tuple of
actions. The shared `CompletionPlan` still takes a list, so the two call
sites that feed it convert explicitly rather than widening a helper the rest
of the suite shares.

* Time the cancellations against the signal, not the clock

Two teardown cancellation cases waited a fixed 0.02s against a 0.2s grace
window before cancelling. A slow scheduler could deliver the cancellation
before the teardown task took its first turn, failing for a reason unrelated
to the shield. Both now wait on the process double's `signalled` event, as
the second-cancellation case already did, so the cancellation lands inside
the grace window by construction.

Bound the readiness-marker poll in the end-to-end case with a deadline and a
`pytest.fail`, mirroring `wait_for_process_death`, so a child that never
starts fails the test instead of hanging the session.

Narrow the suppression around the cancelled run from `BaseException` to the
two outcomes the await can actually produce, so `KeyboardInterrupt` and
unrelated failures still propagate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pull the stage tag apart from the stage index

Every fail-fast test kept `tags["pipeline_stage_index"]` and the typed
`ExecEvent.stage_index` field in lock-step, so an implementation that read
the tag instead of the field would have passed the whole suite. The two can
legitimately disagree: `_build_pipeline_observations` merges
`ExecutionContext.tags` last, so a caller may shadow the coordinator's own
index — which is precisely why the typed fields exist.

Add `test_pipeline_fail_fast_tag_shadowing.py`, which sets the shadowing tags
to an impossible 99 and pins both channels at both levels:

- End to end, through `run_sync` with a real three-stage pipeline, so the
  merge in `_build_pipeline_observations` is the one under test.
- At the emission seam, driving one chosen completion (stage 1 of 4) through
  `_process_completed_task`, which also pins the `cuprum_stage_index` log
  field against the same regression.

Each level also asserts the caller's tag arrives unaltered, so neither can
pass on a run where the shadowing tags never took effect.

Verified by mutation: making `_emit_fail_fast_event` read
`observation.tags["pipeline_stage_index"]` fails both new assertions with
(99, 99) while the rest of the suite stays green.

The real-pipeline runner moves to `_fail_fast_pipeline_support.py` so the
wiring module and this one share one definition of the failing pipeline, and
`make_stage_observations` gains a `tag_overrides` argument that is merged last
for the same reason the production builder merges the caller's tags last.

Refs #73, #285.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Route fail-fast telemetry through adapters (#73)

Publish one typed fail-fast event through observe hooks and let the logging
adapter render its warning. Omit raw arguments and caller tags from that
warning, while preserving metric and tracing projections.

Replace timing-based test coordination with FIFO event gates, ensure
cancellation tests reap reported PIDs on every path, and update the
completion transition, documentation, and snapshots to match the boundary.

* Sanitize pipeline fail-fast telemetry (#73)

Restore the canonical fail-fast WARNING records while emitting an observe
event stripped of argv, environment, working directory, and caller tags.

Read the failing-stage UUID directly from its observation, and cover the
sanitized event, negative-duration boundary, and documented correlation.

* Fix post-rebase pipeline wait integration (#73)

Preserve the lost-wakeup fallback while passing immutable stage context
through the updated wait API. Align the legacy and synthetic test doubles,
stabilise timeout teardown startup, and regenerate the wheel manifest.

* Align pipeline docs with rebase lint rules (#73)

Preserve the rebased fail-fast and cancellation behaviour while applying
the formatter and NumPy return documentation required by the new main
branch lint policy.

* Split adapter projection property assertions

Extract the independent logging, tracing, and metrics assertion groups so
the Hypothesis property remains a concise coordinator without weakening its
projection contracts.

* Harden pipeline fail-fast observability (#73)

Route pipeline-wait warnings through the structured logging adapter,
keep the sanitised event's trusted project label, and serialise same-stage
trace callbacks against exit.

Cover record ordering, process-exit polling isolation, and the fail-fast
tracing race while updating the user and design contracts.

* Split pipeline-wait observability tests (#73)

Move the record-ordering contract out of the async-boundary suite so
each pipeline-wait test module stays below the repository's size limit.

Document the complete split suite and regenerate the wheel-manifest
snapshot for the new test module.

* Report confirmed pipeline fail-fast terminations (#73)

Count one verified outcome for every selected teardown target, excluding
stages that settle after selection before they can be terminated.

Cover the race directly, split batch completion coverage into its own
module, and clarify the completion-record and event channels.

* Group pipeline completion report fields (#73)

Carry each record's action, message, and optional fields in one immutable
report value so the adapter boundary no longer accepts an excessive argument
surface.

Keep the published fields unchanged and assert that teardown outcome fields
remain exclusive to the termination-outcome record.

* Align replayed pipeline expectations

Match target-era pipeline tests to the current wait context and final-stage
fail-fast policy. Restore formatter-required spacing and remove the replayed
documentation blank line.

* Unify tracing protocol boundary (#73)

Keep legacy tracing imports as compatibility re-exports while making the
public module the sole protocol definition. Document the separate direct
pipeline-wait record contract and extract the verified-teardown race setup.

* Refine pipeline fail-fast review contracts (#73)

Preserve the direct pipeline-wait records while adding their execution
correlation token to the structured fail-fast log projection. Clarify
the associated telemetry and protocol boundaries, and improve failure
diagnostics without changing teardown semantics.

* Inject pipeline event clocks (#285)

Make lifecycle and fail-fast event timestamps deterministic at the
observation boundary, and avoid rebuilding logging level maps for every
event. Keep the reporter and structured-event channels distinct in the
user documentation.

* Repair rebase merge artefacts

* Repair rebase documentation and lint gates

Restore target-branch timeout observability documentation that was lost
during the rebase, while retaining the pipeline fail-fast additions.

Keep `TracingHook` below the module-size limit without weakening its
documented attribute contract, and resolve the remaining Markdown and
spelling gate findings.

* Close retried HTTP errors

Close handled `HTTPError` instances before retrying or propagating them,
preventing deferred resource-cleanup warnings in CI.

Make CrossHair fallback tests select their own warning so unrelated
warnings cannot change their assertions.

---------

Co-authored-by: leynos <leynos@rohga>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hypothesis stateful tests for metrics/tracing/logging hooks (cuprum/adapters/metrics_adapter.py)

3 participants