Skip to content

Analysis facts divide by the time the collector actually observed, and a window with a hole in it says so, so collector downtime no longer reads as improvement (#3538 A2) - #3592

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/3538-coverage-honest-windows
Sep 18, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/3538-coverage-honest-windows

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Partial for #3538 — checklist item A2, coverage-blind normalization. Every rate and fraction fact the analysis engine emits now divides by the time the collector actually observed inside the window, not by the nominal window length, and a window the collector only partly observed says so in every analysis payload. The sibling A1 finding (a dead collector's empty window rendered as "All metrics are within normal ranges") was closed in wave 1 by #3553/#3565; this change composes with that envelope rather than redoing it.

The lie the product told

AnalysisContext.PeriodDurationMs — TimeRangeEnd − TimeRangeStart, the nominal window — was the denominator of every wait fraction (PgFactCollector.Waits.cs:57, Lite's twin at :54), of blocking and deadlock events per hour (:116, :165), and of the grouped LCK / CXPACKET family fractions in FactCollectorHelpers. Nothing consulted collection health before dividing. So collector downtime inside a window deflated severity in exact proportion to the data lost: with the collector down for three of a four-hour window during a blocking storm, CXPACKET at a true 25% of observed time read as 6%, blocking at 40/hr read as 10/hr — under every bar on the ladder — and the pass returned a near-clean bill with nothing to flag it. compare_analysis caveated only a window with no facts at all; a half-collected one produced confident numbers and called the deflation "better".

The honest pattern already existed one file over: PgAnomalyDetector.WaitRateWindowSql computes its ms/sec over real LAG intervals — "interval via LAG — never an assumed cadence". This change applies the same measurement to the denominator.

How

One coverage witness, stamped first, on both SKUs. CollectObservedCoverageAsync is step 0 of CollectFactsAsync in PgFactCollector and DuckDbFactCollector (byte-identical SQL, shared dialect — EXTRACT(EPOCH …), LAG, GREATEST, COALESCE; proven on PG 18.4 + TimescaleDB and on DuckDB 1.5.5 with the same text). It reads v_wait_stats — the series the wait fractions themselves are summed from, so numerator and denominator come from one set of rows and cannot disagree about when the collector was up; it is also already the product's canary in the data-span gate and the anomaly baseline gate. It sums, over DISTINCT collection_time inside the window, each collection's interval to its predecessor, with three rules that WindowCoverage's doc comment argues in full:

  • an interval longer than the delta gap policy counts as zero observed time — CollectorDeltaCalculator.DefaultMaxGapSeconds is bound as a parameter ($5), never a literal, because the calculator discarded the delta over such a gap, so crediting any of that time would re-create the deflation in miniature (the task said "cap"; the faithful reading of the calculator is "discard", and the comment says why);
  • the first in-window row finds its predecessor by scanning one policy back ($4) and its interval is clipped to the window start (GREATEST(previous_time, $2)), so observed time never exceeds the nominal window and the fraction needs no clamp;
  • a row with no predecessor contributes nothing — its delta was unknowable, and so is the time it stands for.

The lead-in and tail gaps (a first row with no predecessor; a collector that died mid-window and left nothing after itself to LAG from) are finished in C# from the edge columns, because they are properties of the window's edges the row set cannot see.

The stamp lives on the context. AnalysisContext.Coverage (WindowCoverage: NominalMs, ObservedMs, SampleCount, LargestGapMs, Fraction, IsObserved, IsPartial) and ObservedDurationMs — the divisor. PeriodDurationMs stays, re-documented as the nominal window, for the things legitimately about the window itself: the coverage fraction's own denominator, the period_duration_ms / period_hours metadata that states what was asked for, and the caveat prose. The stamp is done in the collector, not the service, so every path that collects — the scheduled pass, get_analysis_facts, both windows of compare_analysis, a test handing the collector a bare context — gets it before the first division and no caller can forget it. Like the wait read it precedes, it carries no catch: this is the canary series, and a store that cannot answer it should fail the pass loudly, not degrade into "unobserved" and report a dead collector for a store that merely timed out.

Every rate divides by observed time. Wait fractions, BLOCKING_EVENTS, DEADLOCKS in both collectors (rg PeriodDurationMs sweep: no remaining divisor use). The grouped LCK / CXPACKET helpers now sum their constituents' fractions instead of re-dividing, so they are correct under whichever denominator the collector chose without knowing which — and the frozen Dashboard twin, which does not stamp coverage, keeps its pre-#3538 shape exactly. Metadata: wait facts keep period_duration_ms (nominal) and gain coverage_fraction (the divisor is their product); the name is deliberate — FactAdvice.DominantLockMode reads every non-standard *_ms key on the LCK fact as a lock mode, so an observed_duration_ms would have been reported as "the largest single contributor". Blocking/deadlocks keep period_hours and gain observed_hours. An unobserved window emits no rate fact — no fabricated 0, no Infinity; the honest-miss ladder handles it.

Zero coverage composes with the wave-1 envelope. Both services' #3524 branch widens from facts.Count == 0 to || ObservedDurationMs <= 0: the facts that survive an unobserved window are point-in-time ones (server config, trace flags, hardware — read from the latest row regardless of window), and scoring those alone produced a pass whose every windowed rate was absent and whose all-clear or config-only findings still read as "analyzed this window". Same unavailable envelope, with a WindowEmptyMessage variant that says which kind of nothing and how many point-in-time facts could still be read. get_analysis_facts returns unavailable in the same state, pointing at audit_config for the config facts (which deliberately discards coverage — a missed hour changes nothing about what the server is configured to).

Below 70% coverage: a COLLECTION_GAP context fact and caveats everywhere. WindowCoverage.PartialThreshold = 0.70, a judgment about materiality rather than a fleet measurement, and the comment says so and what would revise it: at 70% the old division understated every rate by ≈1.43×, enough to cross a severity band on the wait ladder; the rates themselves are corrected at every coverage, the bar only decides when the reader must be told. The fact has Source = "coverage" — FactScorer.ScoreAll's default arm scores unknown sources 0, so no scorer registration was needed and FactScorer.cs is untouched (another lane is in it). It cannot root a story or notify; it exists to be read in get_analysis_facts next to the facts it qualifies. analyze_server (findings and the all-clear, which becomes "No significant findings in the 25% of this window the collector observed — a PARTIAL reading, not a full all-clear"), get_analysis_facts and compare_analysis carry a coverage block always and a caveat below the bar; compare_analysis extends its existing empty-window caveat pattern to "the COMPARISON/BASELINE window was only partly collected: …" on either or both sides, and excludes the gap fact from the compared keys so it does not pad the stable/new_issues counters (it is an observation of the collector, not the server). The prose is byte-identical across SKUs and the ten new sentences are added to McpMissMessageParityPinTests.

Services return coverage with the facts. CollectAndScoreFactsAsync → (Facts, Coverage), ComparePeriodsAsync → (BaselineFacts, ComparisonFacts, BaselineCoverage, ComparisonCoverage), on both SKUs. Returned rather than parked on a property because those paths have no IsAnalyzing guard and two on-demand callers can overlap. AnalyzeAsync exposes LastWindowCoverage beside WindowEmptyMessage (same lifecycle) and logs a warning on a partial pass, because a scheduled pass has no payload to put the caveat in.

Doc lines the change falsified — the "Value is wait_time_ms / period_duration_ms", "events per hour", "deadlocks per hour" summaries in both collectors — are rewritten; the wait fact's comment also documents the measurement lane's A3 note (the numerator sums concurrent tasks, so the same fraction means different things on 4 vs 64 schedulers) as documented-not-fixed, deferred to A5 with the threshold work.

Why this shape, and what was rejected

  • Collection log as the witness: rejected — it records when a collector ran, not what interval its rows account for, and the gap policy is defined on the latter. The wait series is also what the numerator reads.
  • Computing coverage in the service: rejected — three collect paths plus every test that hands the collector a bare context would each have to remember the stamp, and the fallback when one forgets is the nominal window, i.e. the defect.
  • Always emitting the coverage fact: rejected — it would change facts.Count == 0 semantics the analyze_server answers "all metrics are within normal ranges" when the window collected nothing #3524 envelope keys on and pad every payload and count; the payloads carry a coverage block unconditionally instead.
  • Clipping vs not clipping the first row: clipping keeps observed ≤ nominal by construction. The first row's delta straddles the window start and is counted whole by the numerator exactly as before — a ≤ one-cadence bias the nominal division already carried.

Blast radius

Fully collected windows are unchanged to within one collection cadence — the existing scenario tests (ScenarioTests, FactCollectorTests) assert the same documented fractions. Every partially collected window's rates go up toward their true value (they were only ever deflated), so some findings that were suppressed by a gap will now fire; each such pass carries the gap fact and the caveat. A single in-window reading with nothing before it is now, correctly, the dead-collector shape (unavailable), which is why the wave-1 control fixtures in both SKUs were re-planted as realistic series. deprecated/Dashboard compiles unchanged (verified) and keeps its nominal division — the defect exists there too but porting it needs a T-SQL witness plus its service and tool surfaces, not a smallest-faithful-diff mirror; noted for the coordinator.

What it does NOT do

  • Does not consult collection_log / collector_health (see "rejected" above) and does not make any claim about the monitored server's uptime — a server that was down while the collector kept failing shows as unobserved, which is right for a rate and silent about the server.
  • Does not normalise the wait numerator for scheduler count (A3/A5), re-derive thresholds (A5), or touch FactScorer.
  • Does not add a knob; the 70% bar is a documented constant.
  • Does not mirror into deprecated/Dashboard or touch CHANGELOG, migrations, or schema.

Test plan

Darling (Darling.Tests) — the live classes were executed here against a throwaway PG 18.4 + TimescaleDB 2.28.1 container (Docker context desktop-linux, torn down after): PgFactCollectorTests (census now includes CollectObservedCoverageAsync; AllSql includes CoverageSql; new CoverageSql_… text pin and a source pin, both SKUs, that every rate site divides by ObservedDurationMs and bails when unobserved; the E2E fixture re-planted as baseline + delta and asserting full coverage / no gap fact), AnalyzeWindowEmptyLivePostgresTests (control re-planted as a 15-minute series, asserts the all-clear at ≈100% coverage), new AnalysisCoverageLivePostgresTests (the review's scenario end to end: fraction 0.25 and 10/hr against a 0.0625 / 2.5 nominal reading, gap fact at 0.25 with a 3h largest gap, caveats and coverage blocks in all three tools, a window anchored in the dead stretch → unavailable, then the same window fully collected → no caveat, 2.5/hr), plus AnalysisAsOfAnchorTests and DarlingAnalysisPipelineTests as regression: 32/32 passed, 0 skipped.

Lite (Lite.Tests) — compile-verified here; first execution is CI (net10.0-windows). Written right-by-construction against the same SQL (run on DuckDB 1.5.5 with the same text and the same fixtures: full/died/hole/orphan/predecessor/empty all agree with PG) and the same arithmetic as the Darling tests that ran:

  • TestDataSeeder.SeedWaitStatsAsync plants a physically possible series (baseline reading, then 15-minute deltas ending on the window end) with a coveredMinutes knob; new SeedCollectorGapServerAsync scenario.
  • FactCollectorTests: full coverage leaves the memory-starved 0.694 unchanged (the regression pin) and stamps ≈1.0; the gap scenario yields fractions 4× the nominal division, coverage_fraction 0.25, period_duration_ms × coverage_fraction recovering the divisor, blocking 40/hr and deadlocks 8/hr per observed hour, the gap fact with a 3h largest gap scored 0; an orphan-only window emits no rate facts and keeps point-in-time facts; an empty window stamps unobserved.
  • new AnalysisCoverageTests: the three tools on partial / full / unobserved windows (findings + caveat, scoped all-clear, unavailable with config facts, gap fact in the facts payload, compare_analysis caveat on the partial side only and no gap key among the compared facts), plus WindowCoverage arithmetic and prose.
  • AnalysisWindowEmptyTests control re-planted as a series; McpMissMessageParityPinTests gains the ten shared sentences (replayed locally: all present in both SKUs, with a positive control).

Builds (exact commands from the task): PerformanceMonitor.Analysis, Darling.Service, Lite (EnableWindowsTargeting), Darling.Tests, Lite.Tests — all 0 Warning(s) 0 Error(s); deprecated/Dashboard also builds.

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

See the test plan above — Darling live tests run against PG 18.4 + TimescaleDB 2.28.1; Lite tests compile-verified and CI-first; the coverage SQL executed on both engines with identical results.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • I have tested my changes against at least one SQL Server version (store-side change; verified against the PG 18 store and the DuckDB 1.5.5 store — no SQL Server target query changed)
  • I have not introduced any hardcoded credentials or server names

…d a window with a hole in it says so (#3538 A2)

Every rate and fraction fact in the analysis engine divided by the
NOMINAL window (AnalysisContext.PeriodDurationMs), so collector downtime
inside the window deflated severity in exact proportion to the data
lost: a CXPACKET storm at a true 25% of observed time read as 6% when
the collector was down for three of four hours, blocking at 40/hr read
as 10/hr, and nothing consulted collection health before believing the
numbers. compare_analysis caveated only an ENTIRELY empty window.

Both SKUs' fact collectors now stamp AnalysisContext.Coverage first, from
the wait_stats collection series itself: the sum of LAG intervals inside
the window (the anomaly path's "never an assumed cadence" pattern), with
an interval past the delta gap policy (CollectorDeltaCalculator
.DefaultMaxGapSeconds, bound as a parameter) credited as zero because the
calculator discarded that delta, the first row's interval clipped to the
window, and an orphan row contributing nothing. Wait fractions, blocking
and deadlock rates divide by ObservedDurationMs; the grouped LCK/CXPACKET
families sum their constituents' fractions. An unobserved window emits no
rate fact and composes with the #3524 unavailable envelope even when
point-in-time config facts exist. Below 70% coverage a COLLECTION_GAP
context fact (scored 0) carries the fraction and the largest gap, and
analyze_server / get_analysis_facts / compare_analysis carry a coverage
block and a partial-coverage caveat, byte-identical across SKUs and
pinned by the parity test.

Tests: Lite's seeder now plants a physically possible series (baseline
reading, then deltas), with a collector-gap scenario pinning fractions
4x the nominal division and the gap fact at 25%; Darling's census gains
the coverage witness and its SQL pins, and a live PG test walks the same
scenario through collector, service and tools. Darling's live tests ran
green here against PG 18.4 + TimescaleDB 2.28.1; Lite's are compile-
verified and first execute in CI.

Partial for #3538 (item A2).
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 18, 2026 15:29
{
NominalMs = nominalMs,
ObservedMs = Math.Min(nominalMs, Math.Max(0, observedSeconds * 1000.0)),
SampleCount = (int)Math.Min(int.MaxValue, sampleCount),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: when the window's only in-window row is an orphan (no predecessor within the lookback), sampleCount is 1 but observedSeconds is 0 (the previous_time IS NULL branch contributes 0). BuildCoverage then returns SampleCount = 1, ObservedMs = 0 instead of going through WindowCoverage.Unobserved(nominalMs) (which sets SampleCount = 0). Since IsObserved only checks ObservedMs > 0, behavior is correct (treated as unobserved), but the coverage.sample_count field in the tool payload will read 1 next to unobserved: true, which could read as a contradiction to an API consumer. Same for Lite's twin at Lite/Analysis/DuckDbFactCollector.Waits.cs:138. Might be worth zeroing SampleCount in this branch for consistency with the fully-dead-window case, but this doesn't affect any rate calculation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Declined, deliberately: sample_count = 1 beside unobserved: true is the honest reading of that store, not a contradiction. The two say different things — sample_count counts the readings that landed inside the window (its doc says "the orphan first row included"), unobserved says none of them stood for any knowable interval. Together they distinguish the two dead-collector shapes an operator cares about: 0 / unobserved is a collector that wrote nothing for the whole window; 1 / unobserved is one that came back (or started) inside it and has exactly one reading so far, whose delta the calculator had to discard. Zeroing the count would fold those into one and lose the diagnosis. The Lite test CollectFacts_UnobservedWindow_EmitsNoRateFacts_AndStampsZeroCoverage pins SampleCount == 1 for the orphan case on purpose. Rate math unaffected either way, as you note.

window — and scoring those alone would produce a pass whose every windowed rate is
absent and whose all-clear (or config-only findings) still reads as "analyzed this
window". Nothing was measured over the window; the same envelope says so. */
var unobserved = facts.Count > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: unobserved is true when facts did survive (the point-in-time ones), which is the opposite of what the name suggests at a glance — it really means "the window itself was unobserved, but some facts survived anyway". A name like hasPointInTimeFactsOnly would read more directly at the call site below. Same variable/name in Lite's twin (Lite/Analysis/AnalysisService.cs:202). Non-blocking.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0ace8b6 — renamed to hasPointInTimeFactsOnly in both SKUs, with a one-line comment at the declaration saying what the state is (the window went unobserved but config/trace-flag/hardware facts still read — the case that used to slip past the facts.Count == 0 check).

@claude claude 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.

LGTM — reviewed the coverage-witness SQL (Postgres/DuckDB, byte-identical between Darling and Lite), the WindowCoverage math and edge cases (zero/reversed window, orphan rows, gap-policy discard, clipping), the widened #3524 unavailable envelope, and the analyze_server/get_analysis_facts/compare_analysis payload and caveat changes on both SKUs. Service and MCP-tool layers are line-for-line identical between Lite and Darling, and the new parity pin test extends correctly to the added prose. Left two non-blocking nits inline (a sample_count/unobserved payload inconsistency in an edge case, and a confusingly-named local variable) — neither affects correctness.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Reviewed the coverage-witness change (CollectObservedCoverageAsync / WindowCoverage) across both SKUs plus every consumer (AnalysisService/DarlingAnalysisService, McpAnalysisTools/DarlingMcpTools, FactCollectorHelpers).

Things I specifically checked and found solid:

  • Lite/Darling parity: the coverage SQL is genuinely byte-identical between PgFactCollector.Waits.cs and DuckDbFactCollector.Waits.cs (and there's a test — CoverageSql_ReadsTheWaitSeries_... — that pins this by reading the other file's source at test time). The MCP tool changes in McpAnalysisTools.cs and DarlingMcpTools.cs are line-for-line identical, and McpMissMessageParityPinTests.cs now pins every new caveat string across both SKUs.
  • Gap-policy boundary: CoverageSql's > $5 (strictly greater) matches CollectorDeltaCalculator.CalculateDeltaWithInterval's > maxGapSeconds check exactly, so the coverage witness and the delta calculator can't disagree at the boundary.
  • GroupGeneralLockWaits/GroupParallelismWaits now sum constituent Values instead of re-dividing by PeriodDurationMs — correct, since it makes the grouped fraction denominator-agnostic and doesn't need to know whether the collector stamped coverage (deprecated Dashboard collector still divides by nominal window untouched, which is correct since it's frozen/out of scope).
  • Edge cases: zero/reversed window, no predecessor (orphan) row, fully-unobserved window with only point-in-time facts surviving, and partial-coverage-with-zero-firing-facts are all deliberately handled and covered by tests on both SKUs (AnalysisCoverageTests.cs, AnalysisCoverageLivePostgresTests.cs).
  • Security/SQL: all coverage/wait queries are fully parameterized ($1.. / DuckDBParameter), no string interpolation of untrusted input; timestamps go through the documented AsNaive/naive-UTC convention on the Darling side.

One minor (non-blocking) observation: in compare_analysis, a side with partial coverage but zero firing facts (baselineServerFacts.Count == 0 while baselineCoverage.IsPartial is true) falls into the plain emptyCaveat branch rather than the coverage-specific one, so the caveat won't mention the coverage gap in that specific combination. Doesn't affect correctness (the empty-caveat text is still accurate), just slightly less informative in that one corner case.

Nicely done — very tight parity discipline and thorough edge-case test coverage for a change this central to severity scoring.

@claude claude 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.

LGTM — reviewed the coverage-witness (#3538 A2) denominator fix across both SKUs: shared/byte-identical SQL, parity in AnalysisService/MCP tools, gap-policy boundary consistency, parameterized queries, and edge-case test coverage. No correctness, security, or Lite/Darling parity issues found; one minor non-blocking observation left as a PR comment.

…side even when it produced no facts (both SKUs)
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Fix-now for the one observation: the compare_analysis coverage sentence no longer requires the side to have produced facts — a partly-observed idle side gets "The BASELINE/COMPARISON window was only partly collected: …" alongside the empty-window sentence, both SKUs, comment updated to say why (an idle hour the collector saw a quarter of is still a quarter-seen window). The Darling live class re-ran green against the PG 18.4 + TimescaleDB rig after the change (the compare arm in AnalysisCoverageLivePostgresTests exercises exactly the unobserved-baseline + partial-comparison combination).

@claude claude 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.

LGTM — reviewed the coverage-witness SQL (CoverageSql) for boundary correctness against CollectorDeltaCalculator's own gap policy (both use strict >, so the denominator can never disagree with the numerator about a discarded interval), the C# arithmetic in WindowCoverage/AnalysisContext, and Lite/Darling parity. AnalysisContext, WindowCoverage, and FactCollectorHelpers are shared (one file, not duplicated), and the SKU-specific collector/service/MCP-tool code (PgFactCollector.Waits.cs vs DuckDbFactCollector.Waits.cs, DarlingAnalysisService vs AnalysisService, DarlingMcpTools vs McpAnalysisTools) is a faithful byte-for-byte port with matching guards (ObservedDurationMs <= 0 checks), matching metadata keys, and matching caveat prose (pinned by McpMissMessageParityPinTests). Verified: sample_count/orphan/lead-in/tail-gap logic in BuildCoverage is internally consistent and matches the live-PG and DuckDB test expectations; FactScorer correctly scores the new coverage source at 0 so COLLECTION_GAP can't root a story or trigger amplifiers; the grouped LCK/CXPACKET fractions correctly sum constituent Values rather than re-dividing, so they inherit whichever denominator was stamped; deprecated/Dashboard is correctly left untouched. No correctness, parity, or security issues found.

…l, as WindowCoverage documents, not the nominal window
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

CI's first execution of the Lite suite: 4,034/4,035 green on the first run — every re-planted scenario, the new coverage tests, the parity pin — with one failure in a NEW assertion of mine, not in the product: CollectFacts_UnobservedWindow_EmitsNoRateFacts_AndStampsZeroCoverage expected LargestGapMs to be the whole 4h window for a lone orphan reading at +30 min, but the engine correctly reports the largest SINGLE unobserved stretch — the 210-minute tail (12,600,000 ms), exactly as WindowCoverage.LargestGapMs documents ("lead-in … or the tail — whichever is longest"; only a window with no rows at all reports the nominal length). Corrected the assertion in 71cb7ab with the reasoning inline.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Reviewed for correctness, Lite/Darling parity, security, and performance.

Verified independently (not just from the PR description):

  • CoverageSql text is genuinely byte-identical between PgFactCollector.Waits.cs and DuckDbFactCollector.Waits.cs (direct line-by-line diff, not just the substring-containment test) — same CTE shape, same $1..$5 parameter order, same > boundary on the gap-policy threshold matching CollectorDeltaCalculator's own discard check.
  • Edge cases in BuildCoverage/CoverageSql: 0 rows → Unobserved; a true orphan first row (no predecessor even within the one-policy lookback) correctly contributes 0 to observed_seconds and is finished from first_sample in C#; a non-orphan first row's interval is correctly clipped via GREATEST(previous_time, $2).
  • Every division by ObservedDurationMs/Coverage.Fraction is guarded (<= 0 checks before dividing; WindowCoverage.Fraction guards NominalMs > 0).
  • AnalysisService.cs vs DarlingAnalysisService.cs and McpAnalysisTools.cs vs DarlingMcpTools.cs carry identical coverage-wiring, caveat prose, and the 70% threshold usage.
  • The FactAdvice.DominantLockMode metadata-collision claim (why observed_duration_ms was avoided as a key name) checks out — it only scans *_ms-suffixed keys, and coverage_fraction/observed_hours don't collide.
  • CollectObservedCoverageAsync's new query is bounded by server_id/collection_time on the same index the existing WaitStatsSql already uses — not an added unindexed scan.

Two things I considered flagging but ruled out as non-issues after checking further context:

  • Darling-only self-test for CoverageSql text — but it reads Lite's actual file and does substring-contains against it, and CONTRIBUTING.md's CrossAppGuardCiGateTests mechanism specifically ensures the path filter gating Darling.Tests fires when Lite/ changes, so cross-SKU drift is already covered.
  • The interpolated DELETE FROM ... WHERE server_id = {ServerId} in AnalysisCoverageLivePostgresTests.DeleteRowsAsync — looked like a SQL-concatenation smell, but ServerId is a deterministic hash of a hardcoded test constant (not attacker-controlled), and it's the same pattern already used in AnalyzeWindowEmptyLivePostgresTests.cs, so it's pre-existing convention, not new to this PR.

No correctness bugs, Lite/Darling parity drift, security issues, or performance regressions found.

@claude claude 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.

LGTM — verified CoverageSql byte-identity and parameter order across SKUs, edge-case arithmetic (0 rows, orphan first row, gap-threshold boundary), guarded divisions by ObservedDurationMs, Lite/Darling parity in AnalysisService/DarlingAnalysisService and McpAnalysisTools/DarlingMcpTools, the DominantLockMode metadata-collision claim, and query indexing; no substantive findings.

@erikdarlingdata
erikdarlingdata merged commit 2df8fde into dev Sep 18, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3538-coverage-honest-windows branch September 18, 2026 16:19
erikdarlingdata added a commit that referenced this pull request Sep 18, 2026
…folds one cause into one row, so same-hour-yesterday noise stops reading as a verdict (#3538 A3)

The tool compared ONE window against ONE window and banded every key by its
severity delta on a flat +/-0.1 dead-band. Severity is a threshold-formula
artifact: a saturating ladder read a doubling from 30% to 60% of observed time
as "stable", a trace of a 1%-bar wait read a few seconds an hour as "worse",
one I/O stall surfaced as four to six worse keys, and BAD_ACTOR_<hash> keys
churned into new_issues/resolved_issues on plan-cache eviction alone.

Shared ComparisonBanding (PerformanceMonitor.Analysis) now decides every
verdict for both SKUs: a key with a same-unit per-server baseline (CPU %,
read latency, connections) is banded in that bucket's robust sigma
(delta_sigma, band_source "baseline", trustworthy buckets only); every other
key changes status only when the value moved >= 25% of the larger side AND the
larger side sits >= 0.25 up its own Layer-1 ladder (the scorer's base severity,
so every concerning bar is reused with no number copied); one-sided keys are
new/resolved only when they register on their ladder; BAD_ACTOR_* appearances
are plan_cache_churn; rows carry a physical-cause family and the payload
carries one family row per cause; the rules are stated in band_rules; every
verdict row carries coverage_caveat when either side was partly observed
(#3592 composes). ComparePeriodsAsync on both SKUs returns the dispersion map,
fenced so a failed baseline read degrades to the absolute rule.
erikdarlingdata added a commit that referenced this pull request Sep 18, 2026
…folds one cause into one row, so same-hour-yesterday noise stops reading as a verdict (#3538 A3) (#3634)

* compare_analysis bands each delta by the server's own dispersion and folds one cause into one row, so same-hour-yesterday noise stops reading as a verdict (#3538 A3)

The tool compared ONE window against ONE window and banded every key by its
severity delta on a flat +/-0.1 dead-band. Severity is a threshold-formula
artifact: a saturating ladder read a doubling from 30% to 60% of observed time
as "stable", a trace of a 1%-bar wait read a few seconds an hour as "worse",
one I/O stall surfaced as four to six worse keys, and BAD_ACTOR_<hash> keys
churned into new_issues/resolved_issues on plan-cache eviction alone.

Shared ComparisonBanding (PerformanceMonitor.Analysis) now decides every
verdict for both SKUs: a key with a same-unit per-server baseline (CPU %,
read latency, connections) is banded in that bucket's robust sigma
(delta_sigma, band_source "baseline", trustworthy buckets only); every other
key changes status only when the value moved >= 25% of the larger side AND the
larger side sits >= 0.25 up its own Layer-1 ladder (the scorer's base severity,
so every concerning bar is reused with no number copied); one-sided keys are
new/resolved only when they register on their ladder; BAD_ACTOR_* appearances
are plan_cache_churn; rows carry a physical-cause family and the payload
carries one family row per cause; the rules are stated in band_rules; every
verdict row carries coverage_caveat when either side was partly observed
(#3592 composes). ComparePeriodsAsync on both SKUs returns the dispersion map,
fenced so a failed baseline read degrades to the absolute rule.

* Verdict order is direction before magnitude, so a mixed-direction family's worst member is its regression (#3538 A3 review)

The row sort partitioned only stable from non-stable; worse and better shared
a bucket ordered by relative move, so a family whose BLOCKING_EVENTS fell 60%
while its LCK_M_S rose 30% took the improvement as its worst member and left
families_worse. Rows and families now rank worse > better > stable, then move;
a mixed-direction family pin covers it.
This was referenced Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant