1- """Measurement test for issue #1389 — _POLL_CADENCE p50/p99 validation.
1+ """Measurement test for issue #1389 — _POLL_CADENCE p50/worst validation.
22
33This test creates ``N=10`` automations cold-start, captures the
44``entity-registration-elapsed`` DEBUG log line emitted by
1212sharing one HA testcontainer would make a strict threshold-assert
1313flaky). The "VERDICT" line in the output makes the result machine-greppable.
1414
15- Decision rule (per #1389):
16- - ``p50 < 100 ms`` AND ``p99 < 1.0 s`` → cadence validated, close #1389
17- - otherwise → open follow-up retune PR with the measurement table
15+ The headline metric is ``worst`` (highest observed sample) rather than
16+ a true statistical p99: at N=10 the 99th-percentile index collapses to
17+ the same bucket as p90, so the field would silently drop the
18+ outlier-detection role it was named for. ``worst`` reflects what the
19+ threshold-check actually measures.
1820"""
1921
2022import logging
3234# on every successful registration. Format owned by that function.
3335_ELAPSED_RE = re .compile (r"entity-registration-elapsed:\s*([\d.]+)ms" )
3436
37+ # Distinctive substring of the soft-failure warning ``ha_config_set_automation``
38+ # emits when ``_poll_for_automation_entity`` exhausts ``_POLL_CADENCE`` without
39+ # matching the new automation. The tool pops ``entity_not_verified`` from the
40+ # response, so this warning is the only reliable signal that a registration
41+ # missed. Mirrors the literal in ``tools_config_automations.py``.
42+ _NOT_VERIFIED_WARNING_PREFIX = (
43+ "Automation was submitted to Home Assistant but the entity was not found"
44+ )
45+
3546
3647@pytest .mark .automation
3748@pytest .mark .cleanup
@@ -51,128 +62,151 @@ class TestPollCadenceMeasurement1389:
5162
5263 N_SAMPLES = 10
5364
54- async def test_poll_cadence_p50_p99 (
65+ async def test_poll_cadence_p50_worst (
5566 self , mcp_client , cleanup_tracker , test_data_factory , caplog
5667 ):
57- # Capture the DEBUG records emitted by ``_poll_for_automation_entity``
58- # during this test only. ``caplog`` is per-test-method-scoped, so
59- # records emitted by other tests in the same worker (before or
60- # after this one) don't appear in ``caplog.records`` here. The
61- # rest_client logger-name filter + regex below narrow to the
62- # elapsed-ms records specifically, and ``caplog.clear()`` at entry
63- # is belt-and-suspenders for any setup-phase noise.
64- caplog .set_level (logging .DEBUG , logger = "ha_mcp.client.rest_client" )
65-
66- # Clear any pre-existing matching records so a long-running worker's
67- # earlier automation creates don't leak into our sample set.
68- caplog .clear ()
69-
70- not_verified_count = 0
71- for i in range (self .N_SAMPLES ):
72- config = test_data_factory .automation_config (
73- f"Poll Cadence Measurement 1389-{ i :02d} " ,
68+ # ``caplog.at_level`` saves+restores the logger level on exit;
69+ # ``caplog.set_level`` would leave DEBUG-on-rest_client sticky
70+ # for every later test on the same xdist worker. Wraps only the
71+ # create-loop so unrelated DEBUG noise from teardown stays out.
72+ created_entity_ids : list [str ] = []
73+ try :
74+ with caplog .at_level (logging .DEBUG , logger = "ha_mcp.client.rest_client" ):
75+ caplog .clear ()
76+ not_verified_count = 0
77+ for i in range (self .N_SAMPLES ):
78+ config = test_data_factory .automation_config (
79+ f"Poll Cadence Measurement 1389-{ i :02d} " ,
80+ )
81+ create_data = await safe_call_tool (
82+ mcp_client , "ha_config_set_automation" , {"config" : config }
83+ )
84+ assert create_data .get ("success" ), (
85+ f"automation creation #{ i } failed: { create_data } "
86+ )
87+ entity_id = create_data .get ("entity_id" )
88+ if (
89+ entity_id
90+ and isinstance (entity_id , str )
91+ and entity_id .startswith ("automation." )
92+ ):
93+ cleanup_tracker .track ("automation" , entity_id )
94+ created_entity_ids .append (entity_id )
95+ # ``ha_config_set_automation`` pops ``entity_not_verified``
96+ # off the response before returning and translates the
97+ # miss into a ``warnings`` entry — scan that instead.
98+ # Missing this signal would let a "RETUNE NEEDED" run
99+ # silently print VERDICT=VALIDATED.
100+ warnings = create_data .get ("warnings" ) or []
101+ if any (
102+ isinstance (w , str )
103+ and w .startswith (_NOT_VERIFIED_WARNING_PREFIX )
104+ for w in warnings
105+ ):
106+ not_verified_count += 1
107+
108+ # Parse elapsed-ms values out of captured DEBUG records.
109+ samples : list [float ] = []
110+ for record in caplog .records :
111+ if record .name != "ha_mcp.client.rest_client" :
112+ continue
113+ match = _ELAPSED_RE .search (record .getMessage ())
114+ if match :
115+ samples .append (float (match .group (1 )))
116+
117+ assert samples , (
118+ f"No 'entity-registration-elapsed' DEBUG records captured after "
119+ f"{ self .N_SAMPLES } automation creations. Either the instrumentation "
120+ f"is missing, the logger name changed, or the format regex drifted."
121+ )
122+
123+ # Sample-count invariant pairs with the miss detection above:
124+ # every successful iteration must contribute exactly one elapsed
125+ # record. A partial capture (9 samples + 1 miss not detected by
126+ # the warning scan) would silently skew percentiles otherwise.
127+ assert len (samples ) + not_verified_count == self .N_SAMPLES , (
128+ f"sample-count invariant violated: { len (samples )} samples + "
129+ f"{ not_verified_count } not-verified != { self .N_SAMPLES } attempts"
130+ )
131+
132+ samples .sort ()
133+ n = len (samples )
134+ p50 = statistics .median (samples )
135+ p90 = samples [max (0 , int (0.9 * n ) - 1 )]
136+ # At N=10, ``int(0.99 * (n-1))`` collapses to the same index as
137+ # p90 — a real statistical p99 needs more samples. Use the
138+ # highest observed sample under the name ``worst`` so the
139+ # decision-rule threshold reflects what it actually checks.
140+ worst = samples [- 1 ]
141+ s_min = samples [0 ]
142+ s_max = samples [- 1 ]
143+
144+ p50_ok = p50 < 100.0
145+ worst_ok = worst < 1000.0
146+ no_misses = not_verified_count == 0
147+ verdict = (
148+ "VALIDATED" if (p50_ok and worst_ok and no_misses ) else "RETUNE NEEDED"
149+ )
150+
151+ # ``logger.info`` from a test method is captured by pytest-xdist's
152+ # per-worker buffer and only surfaces on FAILURE — PASSED tests
153+ # silently drop the INFO output. Route through the conftest
154+ # recorder so ``pytest_terminal_summary`` renders the table on
155+ # the master, outside the capture buffer.
156+ record_poll_cadence_measurement (
157+ {
158+ "n" : n ,
159+ "attempts" : self .N_SAMPLES ,
160+ "p50" : p50 ,
161+ "p90" : p90 ,
162+ "worst" : worst ,
163+ "min" : s_min ,
164+ "max" : s_max ,
165+ "not_verified" : not_verified_count ,
166+ "verdict" : verdict ,
167+ "samples" : [round (s , 1 ) for s in samples ],
168+ }
169+ )
170+
171+ sep = "=" * 70
172+ logger .info (sep )
173+ logger .info (
174+ "#1389 _POLL_CADENCE measurement (N=%d successful samples / %d attempts, "
175+ "%d not-verified)" ,
176+ n ,
177+ self .N_SAMPLES ,
178+ not_verified_count ,
74179 )
75- create_data = await safe_call_tool (
76- mcp_client , "ha_config_set_automation" , {"config" : config }
180+ logger .info (sep )
181+ logger .info (
182+ "p50 = %6.1f ms (rule: < 100ms → %s)" ,
183+ p50 ,
184+ "PASS" if p50_ok else "RETUNE" ,
77185 )
78- assert create_data .get ("success" ), (
79- f"automation creation #{ i } failed: { create_data } "
186+ logger .info ("p90 = %6.1f ms" , p90 )
187+ logger .info (
188+ "worst = %6.1f ms (rule: < 1000ms → %s)" ,
189+ worst ,
190+ "PASS" if worst_ok else "RETUNE" ,
80191 )
81- entity_id = create_data .get ("entity_id" )
82- if (
83- entity_id
84- and isinstance (entity_id , str )
85- and entity_id .startswith ("automation." )
86- ):
87- cleanup_tracker .track ("automation" , entity_id )
88- # Track misses — when _poll_for_automation_entity returns None
89- # after exhausting _POLL_CADENCE, the create_data carries this
90- # flag and no elapsed-ms record is emitted for that iteration.
91- # Such misses are themselves evidence that the cadence is too
92- # short (p99 >= sum(_POLL_CADENCE) = 6.0s), so a high miss
93- # rate flips the decision toward RETUNE.
94- if create_data .get ("entity_not_verified" ):
95- not_verified_count += 1
96-
97- # Parse elapsed-ms values out of captured DEBUG records.
98- samples : list [float ] = []
99- for record in caplog .records :
100- if record .name != "ha_mcp.client.rest_client" :
101- continue
102- match = _ELAPSED_RE .search (record .getMessage ())
103- if match :
104- samples .append (float (match .group (1 )))
105-
106- assert samples , (
107- f"No 'entity-registration-elapsed' DEBUG records captured after "
108- f"{ self .N_SAMPLES } automation creations. Either the instrumentation "
109- f"is missing, the logger name changed, or the format regex drifted."
110- )
111-
112- samples .sort ()
113- n = len (samples )
114- p50 = statistics .median (samples )
115- p90 = samples [max (0 , int (0.9 * n ) - 1 )]
116- # For small ``N_SAMPLES`` (=10), ``int(0.99 * (n-1))`` collapses to
117- # the same index as p90 (both → 8 for n=10), silently dropping the
118- # worst-case sample. Use the maximum as p99 so an outlier can't
119- # violate the decision-rule threshold without surfacing.
120- p99 = samples [- 1 ]
121- s_min = samples [0 ]
122- s_max = samples [- 1 ]
123-
124- # Decision rule per #1389 body. Any not-verified iteration is itself
125- # a p99 ≥ 6.0s signal (cadence exhausted without match) and flips to
126- # RETUNE regardless of the measured samples.
127- p50_ok = p50 < 100.0
128- p99_ok = p99 < 1000.0
129- no_misses = not_verified_count == 0
130- verdict = "VALIDATED" if (p50_ok and p99_ok and no_misses ) else "RETUNE NEEDED"
131-
132- # ``logger.info`` from a test method is captured by pytest-xdist's
133- # per-worker buffer and only surfaces on FAILURE — PASSED tests
134- # silently drop the INFO output. Route through the conftest
135- # recorder so ``pytest_terminal_summary`` renders the table on
136- # the master, outside the capture buffer. Logger calls below
137- # remain for local ad-hoc runs (where xdist is off and INFO
138- # streams to the terminal directly).
139- record_poll_cadence_measurement (
140- {
141- "n" : n ,
142- "attempts" : self .N_SAMPLES ,
143- "p50" : p50 ,
144- "p90" : p90 ,
145- "p99" : p99 ,
146- "min" : s_min ,
147- "max" : s_max ,
148- "not_verified" : not_verified_count ,
149- "verdict" : verdict ,
150- "samples" : [round (s , 1 ) for s in samples ],
151- }
152- )
153-
154- sep = "=" * 70
155- logger .info (sep )
156- logger .info (
157- "#1389 _POLL_CADENCE measurement (N=%d successful samples / %d attempts, "
158- "%d not-verified)" ,
159- n ,
160- self .N_SAMPLES ,
161- not_verified_count ,
162- )
163- logger .info (sep )
164- logger .info (
165- "p50 = %6.1f ms (rule: < 100ms → %s)" ,
166- p50 ,
167- "PASS" if p50_ok else "RETUNE" ,
168- )
169- logger .info ("p90 = %6.1f ms" , p90 )
170- logger .info (
171- "p99 = %6.1f ms (rule: < 1000ms → %s)" ,
172- p99 ,
173- "PASS" if p99_ok else "RETUNE" ,
174- )
175- logger .info ("min = %6.1f ms / max = %6.1f ms" , s_min , s_max )
176- logger .info ("samples (sorted, ms): %s" , [round (s , 1 ) for s in samples ])
177- logger .info ("VERDICT: %s" , verdict )
178- logger .info (sep )
192+ logger .info ("min = %6.1f ms / max = %6.1f ms" , s_min , s_max )
193+ logger .info ("samples (sorted, ms): %s" , [round (s , 1 ) for s in samples ])
194+ logger .info ("VERDICT: %s" , verdict )
195+ logger .info (sep )
196+ finally :
197+ # The session-scoped ``cleanup_tracker`` fixture only logs what
198+ # it tracked; without an explicit delete here this test would
199+ # leak 10 automations into the next worker run on a loadscope
200+ # split. Best-effort: per-entity remove, swallow individual
201+ # failures so cleanup doesn't mask the real assertion outcome.
202+ for ent_id in created_entity_ids :
203+ try :
204+ await safe_call_tool (
205+ mcp_client ,
206+ "ha_config_remove_automation" ,
207+ {"identifier" : ent_id },
208+ )
209+ except Exception as cleanup_err :
210+ logger .warning (
211+ "cleanup: failed to remove %s: %s" , ent_id , cleanup_err
212+ )
0 commit comments