feat(detection-core): account for instrument-safe coverage

This commit is contained in:
Marius Ciepluch
2026-10-31 14:46:32 +01:00
parent 4be22046a9
commit 85e2a62ed6
3 changed files with 596 additions and 3 deletions
+1 -1
View File
@@ -94,7 +94,7 @@
- _Requirements: 3.5, 8.2_ - _Requirements: 3.5, 8.2_
- [ ] 4. Expose operational coverage, budget, and readiness gates - [ ] 4. Expose operational coverage, budget, and readiness gates
- [ ] 4.1 (P) Account for instrument reads and every semantic-stage outcome - [x] 4.1 (P) Account for instrument reads and every semantic-stage outcome
- Count reads, questions, sites considered and recognized, transitions, security objects, constraints, determinate outcomes, and findings. - Count reads, questions, sites considered and recognized, transitions, security objects, constraints, determinate outcomes, and findings.
- Put every considered relation in exactly one complete, incomplete, or unresolved bucket. - Put every considered relation in exactly one complete, incomplete, or unresolved bucket.
- Fail unopened or zero-byte tracked input and diagnose the last verified nonzero stage and first missing expected output. - Fail unopened or zero-byte tracked input and diagnose the last verified nonzero stage and first missing expected output.
+341 -2
View File
@@ -8,9 +8,16 @@ instrument failure -- a zero from an unread input is a failed run, never a clean
from __future__ import annotations from __future__ import annotations
import enum import enum
from collections.abc import Mapping import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from openultrasast.core.canonical import JsonValue, content_digest, require_unique_identities
from openultrasast.core.semantics import RelationStatus
INPUT_MANIFEST_SCHEMA = "openultrasast.detection-core.input-manifest.v2"
_DIGEST = re.compile(r"[0-9a-f]{64}")
class InstrumentFailure(ValueError): class InstrumentFailure(ValueError):
"""The run's input was not read; any result from it is invalid.""" """The run's input was not read; any result from it is invalid."""
@@ -54,4 +61,336 @@ def check_reads(reads: Mapping[str, int]) -> None:
raise InstrumentFailure(f"zero-byte read of tracked input: {', '.join(zero)}") raise InstrumentFailure(f"zero-byte read of tracked input: {', '.join(zero)}")
__all__ = ["Coverage", "InstrumentFailure", "ResolutionOutcome", "check_reads"] def _require_text(value: object, field_name: str) -> None:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be nonempty text")
def _require_digest(value: object, field_name: str) -> None:
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
raise ValueError(f"{field_name} must be a full lowercase SHA-256 digest")
def _require_count(value: object, field_name: str) -> None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{field_name} must be a non-negative integer")
def _validate_input_ids(value: object) -> tuple[str, ...]:
if not isinstance(value, tuple) or not value:
raise ValueError("tracked_input_ids must be a nonempty tuple")
for input_id in value:
_require_text(input_id, "tracked input id")
require_unique_identities(value, kind="tracked input")
return value
def derive_input_manifest_digest(tracked_input_ids: tuple[str, ...]) -> str:
"""Derive the identity of the exact ordered tracked-input manifest."""
inputs = _validate_input_ids(tracked_input_ids)
payload: JsonValue = {"schema_version": INPUT_MANIFEST_SCHEMA, "tracked_input_ids": inputs}
return content_digest(payload)
@dataclass(frozen=True)
class InputManifest:
tracked_input_ids: tuple[str, ...]
manifest_digest: str
def __post_init__(self) -> None:
_validate_input_ids(self.tracked_input_ids)
_require_digest(self.manifest_digest, "manifest_digest")
@dataclass(frozen=True)
class InputReadReceipt:
input_id: str
path: str
opened: bool
byte_count: int
content_digest: str | None
def __post_init__(self) -> None:
_require_text(self.input_id, "input_id")
_require_text(self.path, "path")
if not isinstance(self.opened, bool):
raise ValueError("opened must be a boolean")
_require_count(self.byte_count, "byte_count")
if self.content_digest is not None:
_require_digest(self.content_digest, "content_digest")
@dataclass(frozen=True)
class RelationStatusCount:
status: RelationStatus
count: int
def __post_init__(self) -> None:
_validate_relation_status_count(self)
@dataclass(frozen=True)
class ReasonCount:
reason: str
count: int
def __post_init__(self) -> None:
_validate_reason_count(self)
@dataclass(frozen=True)
class CoverageFunnel:
inputs_read: int
questions_formed: int
sites_considered: int
sites_recognized: int
transitions_materialized: int
security_objects_constructed: int
constraints_evaluated: int
determinate_outcomes: int
findings_emitted: int
relation_status_counts: tuple[RelationStatusCount, ...]
unresolved_by_reason: tuple[ReasonCount, ...]
def __post_init__(self) -> None:
_validate_funnel(self)
class CoverageStage(enum.Enum):
INPUTS = "inputs"
QUESTIONS = "questions"
SITES_CONSIDERED = "sites_considered"
SITES_RECOGNIZED = "sites_recognized"
TRANSITIONS = "transitions"
SECURITY_OBJECTS = "security_objects"
CONSTRAINTS = "constraints"
OUTCOMES = "outcomes"
FINDINGS = "findings"
@dataclass(frozen=True)
class StageExpectation:
stage: CoverageStage
expected_nonzero_input: bool
expected_nonzero_output: bool
def __post_init__(self) -> None:
_validate_stage_expectation(self)
@dataclass(frozen=True)
class StageDiagnosis:
last_verified_nonzero_stage: CoverageStage | None
first_failed_output_stage: CoverageStage | None
reasons: tuple[str, ...]
def __post_init__(self) -> None:
for value, field_name in (
(self.last_verified_nonzero_stage, "last_verified_nonzero_stage"),
(self.first_failed_output_stage, "first_failed_output_stage"),
):
if value is not None and not isinstance(value, CoverageStage):
raise ValueError(f"{field_name} must be a CoverageStage or None")
if not isinstance(self.reasons, tuple) or not all(isinstance(reason, str) and reason for reason in self.reasons):
raise ValueError("reasons must be a tuple of nonempty text")
_COUNT_FIELDS = (
"inputs_read",
"questions_formed",
"sites_considered",
"sites_recognized",
"transitions_materialized",
"security_objects_constructed",
"constraints_evaluated",
"determinate_outcomes",
"findings_emitted",
)
def _validate_relation_status_count(value: RelationStatusCount) -> None:
if not isinstance(value.status, RelationStatus):
raise ValueError("status must be a RelationStatus")
_require_count(value.count, "relation status count")
def _validate_reason_count(value: ReasonCount) -> None:
_require_text(value.reason, "reason")
_require_count(value.count, "reason count")
if value.count == 0:
raise ValueError("reason count must be positive")
def _validate_stage_expectation(value: StageExpectation) -> None:
if not isinstance(value.stage, CoverageStage):
raise ValueError("stage must be a CoverageStage")
if not isinstance(value.expected_nonzero_input, bool):
raise ValueError("expected_nonzero_input must be a boolean")
if not isinstance(value.expected_nonzero_output, bool):
raise ValueError("expected_nonzero_output must be a boolean")
def _validate_funnel(funnel: CoverageFunnel) -> None:
for field_name in _COUNT_FIELDS:
_require_count(getattr(funnel, field_name), field_name)
_validate_stage_inequalities(funnel)
if not isinstance(funnel.relation_status_counts, tuple):
raise ValueError("relation_status_counts must be a tuple")
if not all(isinstance(item, RelationStatusCount) for item in funnel.relation_status_counts):
raise ValueError("relation_status_counts must contain RelationStatusCount values")
for item in funnel.relation_status_counts:
_validate_relation_status_count(item)
statuses = tuple(item.status for item in funnel.relation_status_counts)
if len(statuses) != len(set(statuses)):
raise ValueError("duplicate relation status bucket")
if set(statuses) != set(RelationStatus):
raise ValueError("relation_status_counts must contain every relation status")
if sum(item.count for item in funnel.relation_status_counts) != funnel.transitions_materialized:
raise ValueError("relation status buckets must equal transitions_materialized")
_validate_reason_counts(funnel)
def _validate_stage_inequalities(funnel: CoverageFunnel) -> None:
inequalities = (
(funnel.sites_recognized, funnel.sites_considered, "sites_recognized", "sites_considered"),
(funnel.determinate_outcomes, funnel.questions_formed, "determinate_outcomes", "questions_formed"),
(funnel.findings_emitted, funnel.determinate_outcomes, "findings_emitted", "determinate_outcomes"),
)
for downstream, upstream, downstream_name, upstream_name in inequalities:
if downstream > upstream:
raise ValueError(f"{downstream_name} cannot exceed {upstream_name}")
def _validate_reason_counts(funnel: CoverageFunnel) -> None:
if not isinstance(funnel.unresolved_by_reason, tuple):
raise ValueError("unresolved_by_reason must be a tuple")
if not all(isinstance(item, ReasonCount) for item in funnel.unresolved_by_reason):
raise ValueError("unresolved_by_reason must contain ReasonCount values")
for item in funnel.unresolved_by_reason:
_validate_reason_count(item)
reasons = tuple(item.reason for item in funnel.unresolved_by_reason)
if len(reasons) != len(set(reasons)):
raise ValueError("duplicate unresolved reason")
unresolved = sum(item.count for item in funnel.relation_status_counts if item.status is not RelationStatus.COMPLETE)
if sum(item.count for item in funnel.unresolved_by_reason) != unresolved:
raise ValueError("reason counts must equal incomplete and unresolved relations")
def _validate_manifest(manifest: InputManifest) -> None:
inputs = _validate_input_ids(manifest.tracked_input_ids)
_require_digest(manifest.manifest_digest, "manifest digest")
if manifest.manifest_digest != derive_input_manifest_digest(inputs):
raise ValueError("manifest digest cannot be re-derived")
def _validate_receipt_shape(receipt: InputReadReceipt) -> None:
_require_text(receipt.input_id, "input_id")
_require_text(receipt.path, "path")
if not isinstance(receipt.opened, bool):
raise ValueError("opened must be a boolean")
_require_count(receipt.byte_count, "byte_count")
if receipt.content_digest is not None:
_require_digest(receipt.content_digest, "content digest")
def validate_instrument(manifest: InputManifest, receipts: Sequence[InputReadReceipt]) -> None:
"""Refuse unread, zero-byte, missing, extra, or substituted tracked inputs."""
try:
if not isinstance(manifest, InputManifest):
raise ValueError("manifest must be an InputManifest")
_validate_manifest(manifest)
if isinstance(receipts, (str, bytes)) or not isinstance(receipts, Sequence):
raise ValueError("receipts must be a sequence")
_validate_instrument_receipts(manifest, receipts)
except InstrumentFailure:
raise
except (TypeError, ValueError) as exc:
raise InstrumentFailure(str(exc)) from exc
def _validate_instrument_receipts(manifest: InputManifest, receipts: Sequence[InputReadReceipt]) -> None:
by_id: dict[str, InputReadReceipt] = {}
for receipt in receipts:
if not isinstance(receipt, InputReadReceipt):
raise InstrumentFailure("receipts must contain InputReadReceipt values")
_validate_receipt_shape(receipt)
if receipt.input_id in by_id:
raise InstrumentFailure(f"duplicate input receipt: {receipt.input_id}")
if receipt.input_id not in manifest.tracked_input_ids:
raise InstrumentFailure(f"unexpected input receipt: {receipt.input_id}")
by_id[receipt.input_id] = receipt
missing = tuple(input_id for input_id in manifest.tracked_input_ids if input_id not in by_id)
if missing:
raise InstrumentFailure(f"missing input receipt: {', '.join(missing)}")
for input_id in manifest.tracked_input_ids:
receipt = by_id[input_id]
if not receipt.opened:
raise InstrumentFailure(f"tracked input not opened: {input_id} ({receipt.path})")
if receipt.byte_count == 0:
raise InstrumentFailure(f"zero-byte tracked input: {input_id} ({receipt.path})")
if receipt.content_digest is None:
raise InstrumentFailure(f"content digest missing for tracked input: {input_id}")
def _stage_counts(funnel: CoverageFunnel) -> dict[CoverageStage, int]:
return dict(zip(CoverageStage, (getattr(funnel, field_name) for field_name in _COUNT_FIELDS), strict=True))
def _validate_expectations(expected: Sequence[StageExpectation]) -> tuple[StageExpectation, ...]:
if isinstance(expected, (str, bytes)) or not isinstance(expected, Sequence):
raise ValueError("expected must be a sequence")
values = tuple(expected)
if not all(isinstance(item, StageExpectation) for item in values):
raise ValueError("expected must contain StageExpectation values")
for item in values:
_validate_stage_expectation(item)
stages = tuple(item.stage for item in values)
if len(stages) != len(set(stages)):
raise ValueError("duplicate coverage stage expectation")
if set(stages) != set(CoverageStage):
raise ValueError("expectations must cover every coverage stage")
if stages != tuple(CoverageStage):
raise ValueError("expectations must use canonical coverage order")
return values
def diagnose_first_zero(funnel: CoverageFunnel, expected: Sequence[StageExpectation]) -> StageDiagnosis:
"""Identify the first expected output that is zero without treating allowed zeros as failures."""
if not isinstance(funnel, CoverageFunnel):
raise ValueError("funnel must be a CoverageFunnel")
_validate_funnel(funnel)
expectations = _validate_expectations(expected)
counts = _stage_counts(funnel)
last_nonzero: CoverageStage | None = None
previous_output: int | None = None
for expectation in expectations:
output = counts[expectation.stage]
if expectation.expected_nonzero_input and (previous_output is None or previous_output == 0):
reason = f"{expectation.stage.value}:expected_nonzero_input"
return StageDiagnosis(last_nonzero, expectation.stage, (reason,))
if expectation.expected_nonzero_output and output == 0:
reason = f"{expectation.stage.value}:expected_nonzero_output"
return StageDiagnosis(last_nonzero, expectation.stage, (reason,))
if output > 0:
last_nonzero = expectation.stage
previous_output = output
return StageDiagnosis(last_nonzero, None, ())
__all__ = [
"Coverage",
"CoverageFunnel",
"CoverageStage",
"InputManifest",
"InputReadReceipt",
"InstrumentFailure",
"ReasonCount",
"RelationStatusCount",
"ResolutionOutcome",
"StageDiagnosis",
"StageExpectation",
"check_reads",
"derive_input_manifest_digest",
"diagnose_first_zero",
"validate_instrument",
]
+254
View File
@@ -0,0 +1,254 @@
"""Contract tests for the v2 coverage funnel and instrument gate."""
from dataclasses import replace
import pytest
from openultrasast.core.coverage import (
CoverageFunnel,
CoverageStage,
InputManifest,
InputReadReceipt,
InstrumentFailure,
ReasonCount,
RelationStatusCount,
StageExpectation,
derive_input_manifest_digest,
diagnose_first_zero,
validate_instrument,
)
from openultrasast.core.semantics import RelationStatus
DIGEST_A = "a" * 64
DIGEST_B = "b" * 64
def _manifest() -> InputManifest:
inputs = ("src/app.py", "src/lib.py")
return InputManifest(inputs, derive_input_manifest_digest(inputs))
def _receipts() -> tuple[InputReadReceipt, ...]:
return (
InputReadReceipt("src/app.py", "src/app.py", True, 12, DIGEST_A),
InputReadReceipt("src/lib.py", "src/lib.py", True, 8, DIGEST_B),
)
def _funnel(**overrides: object) -> CoverageFunnel:
values: dict[str, object] = {
"inputs_read": 2,
"questions_formed": 2,
"sites_considered": 4,
"sites_recognized": 3,
"transitions_materialized": 4,
"security_objects_constructed": 2,
"constraints_evaluated": 2,
"determinate_outcomes": 1,
"findings_emitted": 1,
"relation_status_counts": (
RelationStatusCount(RelationStatus.COMPLETE, 2),
RelationStatusCount(RelationStatus.INCOMPLETE, 1),
RelationStatusCount(RelationStatus.UNRESOLVED, 1),
),
"unresolved_by_reason": (ReasonCount("missing_constraint", 1), ReasonCount("ambiguous_call", 1)),
}
values.update(overrides)
return CoverageFunnel(**values) # type: ignore[arg-type]
def _expectations(*, findings_expected: bool = True) -> tuple[StageExpectation, ...]:
return tuple(
StageExpectation(stage, stage is not CoverageStage.INPUTS, findings_expected if stage is CoverageStage.FINDINGS else True)
for stage in CoverageStage
)
def test_coverage_v2_instrument_reads_nonzero_source() -> None:
assert __file__
with open(__file__, "rb") as source:
assert len(source.read()) > 0
def test_input_manifest_and_receipts_validate_exactly() -> None:
validate_instrument(_manifest(), _receipts())
@pytest.mark.parametrize(
("opened", "byte_count", "digest", "match"),
[
(False, 12, DIGEST_A, "not opened"),
(True, 0, None, "zero-byte"),
],
)
def test_unopened_or_zero_byte_input_is_an_instrument_failure(opened: bool, byte_count: int, digest: str | None, match: str) -> None:
receipt = InputReadReceipt("src/app.py", "src/app.py", opened, byte_count, digest)
with pytest.raises(InstrumentFailure, match=match):
validate_instrument(_manifest(), (receipt, _receipts()[1]))
def test_instrument_refuses_missing_extra_and_duplicate_receipts() -> None:
manifest = _manifest()
receipts = _receipts()
with pytest.raises(InstrumentFailure, match="missing input receipt"):
validate_instrument(manifest, receipts[:1])
extra = InputReadReceipt("src/extra.py", "src/extra.py", True, 1, DIGEST_A)
with pytest.raises(InstrumentFailure, match="unexpected input receipt"):
validate_instrument(manifest, receipts + (extra,))
with pytest.raises(InstrumentFailure, match="duplicate input receipt"):
validate_instrument(manifest, receipts + (receipts[0],))
def test_instrument_revalidates_manifest_and_receipt_after_tampering() -> None:
manifest = _manifest()
object.__setattr__(manifest, "manifest_digest", DIGEST_A)
with pytest.raises(InstrumentFailure, match="manifest digest"):
validate_instrument(manifest, _receipts())
receipt = _receipts()[0]
object.__setattr__(receipt, "content_digest", "UPPER")
with pytest.raises(InstrumentFailure, match="content digest"):
validate_instrument(_manifest(), (receipt, _receipts()[1]))
def test_manifest_rejects_duplicate_or_malformed_input_ids() -> None:
with pytest.raises(ValueError, match="duplicate"):
InputManifest(("src/app.py", "src/app.py"), DIGEST_A)
with pytest.raises(ValueError, match="nonempty text"):
InputManifest(("",), DIGEST_A)
def test_funnel_accounts_every_relation_once() -> None:
funnel = _funnel()
assert sum(item.count for item in funnel.relation_status_counts) == funnel.transitions_materialized
@pytest.mark.parametrize(
"updates",
[
{"relation_status_counts": (RelationStatusCount(RelationStatus.COMPLETE, 4),)},
{
"relation_status_counts": (
RelationStatusCount(RelationStatus.COMPLETE, 2),
RelationStatusCount(RelationStatus.COMPLETE, 1),
RelationStatusCount(RelationStatus.UNRESOLVED, 1),
)
},
{
"relation_status_counts": (
RelationStatusCount(RelationStatus.COMPLETE, 3),
RelationStatusCount(RelationStatus.INCOMPLETE, 1),
RelationStatusCount(RelationStatus.UNRESOLVED, 1),
)
},
{"unresolved_by_reason": (ReasonCount("missing_constraint", 1),)},
],
)
def test_funnel_refuses_missing_duplicate_or_mismatched_accounting(updates: dict[str, object]) -> None:
with pytest.raises(ValueError):
_funnel(**updates)
@pytest.mark.parametrize("field", ["inputs_read", "questions_formed", "sites_considered", "findings_emitted"])
def test_funnel_refuses_negative_or_boolean_counts(field: str) -> None:
with pytest.raises(ValueError, match=field):
_funnel(**{field: -1})
with pytest.raises(ValueError, match=field):
_funnel(**{field: True})
@pytest.mark.parametrize(
"updates",
[
{"sites_considered": 1, "sites_recognized": 2},
{"questions_formed": 1, "determinate_outcomes": 2, "findings_emitted": 1},
{"determinate_outcomes": 1, "findings_emitted": 2},
],
)
def test_funnel_refuses_impossible_stage_counts_at_construction(updates: dict[str, object]) -> None:
with pytest.raises(ValueError, match="cannot exceed"):
_funnel(**updates)
@pytest.mark.parametrize(
("field", "value", "match"),
[
("sites_recognized", 5, "sites_recognized cannot exceed sites_considered"),
("determinate_outcomes", 3, "determinate_outcomes cannot exceed questions_formed"),
("findings_emitted", 2, "findings_emitted cannot exceed determinate_outcomes"),
],
)
def test_diagnosis_revalidates_impossible_stage_counts_after_tampering(field: str, value: int, match: str) -> None:
funnel = _funnel()
object.__setattr__(funnel, field, value)
with pytest.raises(ValueError, match=match):
diagnose_first_zero(funnel, _expectations())
def test_valid_zero_is_not_diagnosed_as_a_failure() -> None:
funnel = _funnel(findings_emitted=0)
diagnosis = diagnose_first_zero(funnel, _expectations(findings_expected=False))
assert diagnosis.first_failed_output_stage is None
assert diagnosis.last_verified_nonzero_stage is CoverageStage.OUTCOMES
assert diagnosis.reasons == ()
@pytest.mark.parametrize("failed_stage", tuple(CoverageStage))
def test_each_injected_first_zero_names_the_failing_stage(failed_stage: CoverageStage) -> None:
counts = {stage: 1 for stage in CoverageStage}
failed_index = tuple(CoverageStage).index(failed_stage)
for stage in tuple(CoverageStage)[failed_index:]:
counts[stage] = 0
funnel = CoverageFunnel(
inputs_read=counts[CoverageStage.INPUTS],
questions_formed=counts[CoverageStage.QUESTIONS],
sites_considered=counts[CoverageStage.SITES_CONSIDERED],
sites_recognized=counts[CoverageStage.SITES_RECOGNIZED],
transitions_materialized=counts[CoverageStage.TRANSITIONS],
security_objects_constructed=counts[CoverageStage.SECURITY_OBJECTS],
constraints_evaluated=counts[CoverageStage.CONSTRAINTS],
determinate_outcomes=counts[CoverageStage.OUTCOMES],
findings_emitted=counts[CoverageStage.FINDINGS],
relation_status_counts=(
RelationStatusCount(RelationStatus.COMPLETE, counts[CoverageStage.TRANSITIONS]),
RelationStatusCount(RelationStatus.INCOMPLETE, 0),
RelationStatusCount(RelationStatus.UNRESOLVED, 0),
),
unresolved_by_reason=(),
)
diagnosis = diagnose_first_zero(funnel, _expectations())
assert diagnosis.first_failed_output_stage is failed_stage
assert diagnosis.last_verified_nonzero_stage is (None if failed_index == 0 else tuple(CoverageStage)[failed_index - 1])
assert diagnosis.reasons == (f"{failed_stage.value}:expected_nonzero_output",)
def test_expectations_must_be_complete_unique_and_ordered() -> None:
funnel = _funnel()
expectations = _expectations()
with pytest.raises(ValueError, match="every coverage stage"):
diagnose_first_zero(funnel, expectations[:-1])
with pytest.raises(ValueError, match="canonical coverage order"):
diagnose_first_zero(funnel, tuple(reversed(expectations)))
with pytest.raises(ValueError, match="duplicate"):
diagnose_first_zero(funnel, expectations[:-1] + (expectations[0],))
def test_diagnosis_revalidates_nested_counts_and_expectations_after_tampering() -> None:
funnel = _funnel()
object.__setattr__(funnel.relation_status_counts[0], "count", -1)
with pytest.raises(ValueError, match="relation status count"):
diagnose_first_zero(funnel, _expectations())
expectation = _expectations()[0]
object.__setattr__(expectation, "expected_nonzero_output", 1)
with pytest.raises(ValueError, match="expected_nonzero_output"):
diagnose_first_zero(_funnel(), (expectation,) + _expectations()[1:])
def test_stage_contracts_reject_wrong_runtime_types() -> None:
with pytest.raises(ValueError):
StageExpectation("inputs", True, True) # type: ignore[arg-type]
with pytest.raises(ValueError):
RelationStatusCount("complete", 1) # type: ignore[arg-type]
with pytest.raises(ValueError):
ReasonCount("", 1)
with pytest.raises(ValueError):
replace(_expectations()[0], expected_nonzero_output=1) # type: ignore[arg-type]