orchestrata_ultimate.factory.detectors.rules
Reference › orchestrata_ultimate › factory › detectors › rules
rules is the largest module of the C3 detector set (SPEC-033 row B22, ADR-0029): one function per rule, plus the registry the engine iterates. Each rule takes a whole-document RuleContext (text, source name, lines, located SegmentInfo records and repository root, built once by build_context using clause) and returns Finding records. ALL_RULES is built by _build_registry from three tiers: _tier1_registry (blocking; the dictionary-family rules with measured precision, built by _tier1_dictionary_registry, plus structural rules from _tier1_structural_registry), _tier2_registry (advisory) and _tier3_registry (questions).
Two global rules apply to every rule. Conditional clauses are exempt from _comparative_no_baseline and _negative_statement, and _finding_from_segment halves the weight of any finding inside a rationale clause. _lexicon_rule builds the many rules that report one finding per lexicon hit. Hand-written rules include _unresolved_placeholder, _missing_unit, _exact_duplicate, _template_violation, _incomplete_reference (checks REQ, AC, ADR and SPEC ids against the repository, reusing the pinning sources in assumptions.sources), _non_atomic_criterion, _coordination_ambiguity, _vague_pronoun, _quantifier_scope, _operator_placement, _boundary_word and _underspecified_head_noun. No rule sets a completeness verdict (ADR-0011), and a passive-voice rule is deliberately absent. The docstring lists rules deferred until a design contract and glossary exist. The decision phase and the scripts.factory_detectors CLI import it; tests are in tests/factory/test_detector_rules.py.
At a glance
| Source | src/orchestrata_ultimate/factory/detectors/rules.py |
| Lines of code | 1095 |
| Classes | 2 |
| Functions | 29 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.paths, orchestrata_ultimate.factory.assumptions.models, orchestrata_ultimate.factory.assumptions.sources, orchestrata_ultimate.factory.detectors.clause, orchestrata_ultimate.factory.detectors.lexicons, orchestrata_ultimate.factory.detectors.models |
| Imported by | orchestrata_ultimate.factory.decision.phase, orchestrata_ultimate.factory.detectors.engine, scripts.factory_detectors |
| Tests that import it | tests/factory/test_detector_engine.py, tests/factory/test_detector_rules.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
ALL_RULES |
dict[str, tuple[RuleMeta, RuleFn]] |
_build_registry() |
Classes
SegmentInfo (dataclass)
Decorators: dataclass(frozen=True) · Line 67
One clause segment located at an absolute line + column offset.
| Attribute | Type | Default |
|---|---|---|
line_no |
int |
|
line_text |
str |
|
sentence |
str |
|
sentence_col |
int |
|
segment |
Segment |
| Method | Signature | Summary |
|---|---|---|
abs_span |
abs_span(local_start: int, local_end: int) -> tuple[int, int] |
Map a (start, end) offset local to segment.text to line columns. |
RuleContext (dataclass)
Decorators: dataclass(frozen=True) · Line 83
Everything a rule function needs, precomputed once per document.
| Attribute | Type | Default |
|---|---|---|
text |
str |
|
source_name |
str |
|
lines |
list[str] |
|
segments |
list[SegmentInfo] |
|
repo_root |
Path |
Functions
| Function | Signature | Summary |
|---|---|---|
build_context |
build_context(text: str, source_name: str, *, repo_root: Path | None = None) -> RuleContext |
Build a :class:RuleContext for text: sentences -> segments. |
_finding_from_segment |
_finding_from_segment(rule_id: str, tier: Tier, seg_info: SegmentInfo, local_start: int, local_end: int, matched_text: str, message: str, *, question: str | None = None) -> Finding |
Build a :class:Finding from a match local to seg_info.segment.text. |
_lexicon_rule |
_lexicon_rule(rule_id: str, tier: Tier, lexicon: frozenset[str], message_template: str, *, question_template: str | None = None, word_boundary: bool = True) -> RuleFn |
Build a rule function: one finding per lexicon hit, in every segment. |
_unresolved_placeholder |
_unresolved_placeholder(ctx: RuleContext) -> list[Finding] |
Flag TBD/TBR/TODO/FIXME/???/<placeholder>/XXX anywhere in a line. |
_strip_punct |
_strip_punct(token: str) -> str |
|
_missing_unit |
_missing_unit(ctx: RuleContext) -> list[Finding] |
A bare number with no unit token within 2 following tokens. |
_exact_duplicate |
_exact_duplicate(ctx: RuleContext) -> list[Finding] |
Identical normalized AC text appearing twice (second+ occurrence flagged). |
_template_violation |
_template_violation(ctx: RuleContext) -> list[Finding] |
A line clearly attempting the AC bullet shape but not matching it. |
_known_ac_req_and_adr_refs |
_known_ac_req_and_adr_refs(repo_root: Path) -> set[str] |
Known AC-/REQ-/ADR- refs, reusing the B13 pinning sources. |
_known_spec_ids |
_known_spec_ids(repo_root: Path) -> set[str] |
Known SPEC-NNN ids -- one per specs/NNN-*/ directory. |
_incomplete_reference |
_incomplete_reference(ctx: RuleContext) -> list[Finding] |
A REQ-/AC-/ADR-/SPEC- token that does not resolve against the repo. |
_comparative_has_baseline |
_comparative_has_baseline(segment_text: str, match_start: int) -> bool |
|
_comparative_no_baseline |
_comparative_no_baseline(ctx: RuleContext) -> list[Finding] |
Comparative lexicon hit with no baseline; global rule 1 exempts conditionals. |
_negative_statement |
_negative_statement(ctx: RuleContext) -> list[Finding] |
Negation-marker hit; global rule 1 exempts conditional segments. |
_has_verb_token |
_has_verb_token(text: str) -> bool |
|
_non_atomic_criterion |
_non_atomic_criterion(ctx: RuleContext) -> list[Finding] |
A main segment joining >=2 distinct verb phrases with 'and'. |
_coordination_ambiguity |
_coordination_ambiguity(ctx: RuleContext) -> list[Finding] |
A sentence containing both 'and' and 'or' with no explicit grouping. |
_vague_pronoun |
_vague_pronoun(ctx: RuleContext) -> list[Finding] |
A vague pronoun in subject position (first token of a segment). |
_quantifier_scope |
_quantifier_scope(ctx: RuleContext) -> list[Finding] |
A quantifier and a negation marker co-occurring in one segment. |
_operator_placement |
_operator_placement(ctx: RuleContext) -> list[Finding] |
only/also/even not immediately preceding a verb or a number. |
_nearby_number |
_nearby_number(text: str, match_start: int, match_end: int, window_tokens: int = 3) -> str | None |
A digit-bearing token within window_tokens of the match, if any. |
_boundary_word |
_boundary_word(ctx: RuleContext) -> list[Finding] |
A boundary word within a few tokens of a number -- interval ambiguity. |
_underspecified_head_noun |
_underspecified_head_noun(ctx: RuleContext) -> list[Finding] |
'the data'/'the information'/'the content'/'the system' with no defining qualifier immediately following (heuristic: a following relative/prepositional clause like "returned by X" or "that Y"… |
_tier1_dictionary_registry |
_tier1_dictionary_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
The Femmer-family dictionary rules (measured precision) + ISO placeholders. |
_tier1_structural_registry |
_tier1_structural_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
The structural (non-dictionary-family) BLOCKING rules. |
_tier1_registry |
_tier1_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
|
_tier2_registry |
_tier2_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
|
_tier3_registry |
_tier3_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
|
_build_registry traces |
_build_registry() -> dict[str, tuple[RuleMeta, RuleFn]] |
Build the rule registry (see :data:ALL_RULES) from its three tiers. |
Source
"""One detector per rule for the SPEC-033 C3 set (B22, ADR-0029).
Each rule function takes the whole-document :class:`RuleContext` and returns
every :class:`~.models.Finding` it produces; :data:`ALL_RULES` is the
registry the engine iterates. Two global rules apply across rule functions
(not per-rule logic duplicated everywhere):
1. **Conditional exemption** (ADR-0029 rule 1) -- ``comparative-no-baseline``
and ``negative-statement`` skip any segment whose ``kind == "conditional"``
entirely, regardless of what else is true of it.
2. **Rationale down-weighting** (ADR-0029 rule 3) -- every segment-based
finding's ``weight`` is :data:`~.models.RATIONALE_WEIGHT` when
``segment.kind == "rationale"``, else ``1.0``. Applied uniformly by
:func:`_finding_from_segment`.
No detector here may ever set a completeness verdict (ADR-0011); every
function below returns *findings*, and a passive-voice rule is deliberately
absent (ADR-0029 rejects it: not an ISO 29148 §5.2.7 smell, not in Femmer's
catalogue, and INCOSE classifies it as not tool-automatable).
Deferred (documented, not built here; needs the B8 design contract /
glossary): undefined-term-vs-glossary, unmapped-requirement, orphan-design-
node, node-with-no-AC/anchor, dangling-interface-reference.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.paths import get_project_root
from orchestrata_ultimate.factory.assumptions.models import normalize
from orchestrata_ultimate.factory.assumptions.sources import AcSource, AdrSource
from orchestrata_ultimate.factory.detectors.clause import Segment, segment, sentences
from orchestrata_ultimate.factory.detectors.lexicons import (
AMBIGUOUS_ADVERBS_ADJECTIVES,
BOUNDARY_WORDS,
COMPARATIVES,
DEICTIC,
LOOPHOLES,
NEGATION_MARKERS,
OPEN_ENDED,
PLACEHOLDERS,
SOLUTION_LANGUAGE_NOUNS,
SUBJECTIVE,
SUPERLATIVES,
TEMPORAL_NONSPECIFIC,
UNDERSPECIFIED_HEAD_NOUNS,
VAGUE_PRONOUNS,
WEAK_MODALS,
find_lexicon_matches,
)
from orchestrata_ultimate.factory.detectors.models import (
RATIONALE_WEIGHT,
Finding,
RuleMeta,
Tier,
)
RuleFn = Callable[["RuleContext"], list[Finding]]
@dataclass(frozen=True)
class SegmentInfo:
"""One clause segment located at an absolute line + column offset."""
line_no: int
line_text: str
sentence: str
sentence_col: int
segment: Segment
def abs_span(self, local_start: int, local_end: int) -> tuple[int, int]:
"""Map a (start, end) offset local to ``segment.text`` to line columns."""
base = self.sentence_col + self.segment.start
return (base + local_start, base + local_end)
@dataclass(frozen=True)
class RuleContext:
"""Everything a rule function needs, precomputed once per document."""
text: str
source_name: str
lines: list[str]
segments: list[SegmentInfo]
repo_root: Path
def build_context(
text: str, source_name: str, *, repo_root: Path | None = None
) -> RuleContext:
"""Build a :class:`RuleContext` for ``text``: sentences -> segments."""
root = repo_root if repo_root is not None else get_project_root()
lines = text.splitlines()
seg_infos: list[SegmentInfo] = []
cursor_by_line: dict[int, int] = {}
for line_no, sent in sentences(text):
line_text = lines[line_no - 1] if 0 <= line_no - 1 < len(lines) else sent
start_search = cursor_by_line.get(line_no, 0)
col = line_text.find(sent, start_search)
if col == -1:
col = line_text.find(sent)
if col == -1:
col = 0
cursor_by_line[line_no] = col + len(sent)
seg_infos.extend(
SegmentInfo(
line_no=line_no,
line_text=line_text,
sentence=sent,
sentence_col=col,
segment=seg,
)
for seg in segment(sent)
)
return RuleContext(
text=text,
source_name=source_name,
lines=lines,
segments=seg_infos,
repo_root=root,
)
def _finding_from_segment(
rule_id: str,
tier: Tier,
seg_info: SegmentInfo,
local_start: int,
local_end: int,
matched_text: str,
message: str,
*,
question: str | None = None,
) -> Finding:
"""Build a :class:`Finding` from a match local to ``seg_info.segment.text``.
Applies global rule 3 (rationale down-weighting) uniformly.
"""
span = seg_info.abs_span(local_start, local_end)
weight = RATIONALE_WEIGHT if seg_info.segment.kind == "rationale" else 1.0
return Finding.new(
rule_id=rule_id,
tier=tier,
line=seg_info.line_no,
span=span,
matched_text=matched_text,
message=message,
context_line=seg_info.line_text,
weight=weight,
question=question,
)
def _lexicon_rule(
rule_id: str,
tier: Tier,
lexicon: frozenset[str],
message_template: str,
*,
question_template: str | None = None,
word_boundary: bool = True,
) -> RuleFn:
"""Build a rule function: one finding per lexicon hit, in every segment."""
def _rule(ctx: RuleContext) -> list[Finding]:
findings: list[Finding] = []
for seg_info in ctx.segments:
for m in find_lexicon_matches(
seg_info.segment.text, lexicon, word_boundary=word_boundary
):
matched = m.group(0)
question = (
question_template.format(matched=matched)
if question_template
else None
)
findings.append(
_finding_from_segment(
rule_id,
tier,
seg_info,
m.start(),
m.end(),
matched,
message_template.format(matched=matched),
question=question,
)
)
return findings
return _rule
# ---------------------------------------------------------------------------
# Tier 1 -- BLOCKING
# ---------------------------------------------------------------------------
_subjective_language = _lexicon_rule(
"subjective-language", Tier.BLOCKING, SUBJECTIVE, "subjective language: '{matched}'"
)
_ambiguous_adverb_adjective = _lexicon_rule(
"ambiguous-adverb-adjective",
Tier.BLOCKING,
AMBIGUOUS_ADVERBS_ADJECTIVES,
"ambiguous adverb/adjective: '{matched}'",
)
_loophole = _lexicon_rule(
"loophole", Tier.BLOCKING, LOOPHOLES, "loophole / escape clause: '{matched}'"
)
_open_ended_term = _lexicon_rule(
"open-ended-term",
Tier.BLOCKING,
OPEN_ENDED,
"open-ended, non-verifiable term: '{matched}'",
)
_weak_modal = _lexicon_rule(
"weak-modal", Tier.BLOCKING, WEAK_MODALS, "weak modal: '{matched}'"
)
def _unresolved_placeholder(ctx: RuleContext) -> list[Finding]:
"""Flag TBD/TBR/TODO/FIXME/???/<placeholder>/XXX anywhere in a line."""
findings: list[Finding] = [
Finding.new(
rule_id="unresolved-placeholder",
tier=Tier.BLOCKING,
line=line_no,
span=(m.start(), m.end()),
matched_text=m.group(0),
message=f"unresolved placeholder: '{m.group(0)}'",
context_line=line,
)
for line_no, line in enumerate(ctx.lines, start=1)
for m in find_lexicon_matches(line, PLACEHOLDERS, word_boundary=True)
]
return findings
_TOKEN_RE = re.compile(r"\S+")
_BARE_NUMBER_RE = re.compile(r"^\d+(\.\d+)?$")
_UNITS = frozenset(
{
"ms",
"s",
"sec",
"secs",
"second",
"seconds",
"minute",
"minutes",
"hour",
"hours",
"%",
"mb",
"gb",
"kb",
"byte",
"bytes",
"px",
"item",
"items",
"retry",
"retries",
"attempt",
"attempts",
"day",
"days",
"req/s",
"qps",
}
)
# Preceding tokens that make a following bare number an ordinal/id/version
# reference rather than an unqualified magnitude (missing-unit's exemption).
_ORDINAL_CONTEXT_WORDS = frozenset(
{
"step",
"item",
"section",
"phase",
"stage",
"version",
"v",
"figure",
"fig",
"table",
"page",
"#",
# A number after "port"/"ports" is an identifier, not a magnitude
# ("listens on port 8080" carries no missing unit).
"port",
"ports",
}
)
def _strip_punct(token: str) -> str:
return token.strip(",.;:()")
def _missing_unit(ctx: RuleContext) -> list[Finding]:
"""A bare number with no unit token within 2 following tokens.
Skips: the first token on a line ending in "." (a markdown numbered-list
marker, e.g. "1. Do the thing"), numbers preceded by an ordinal/id/
version context word (:data:`_ORDINAL_CONTEXT_WORDS`), and any token that
isn't a *bare* number in the first place -- ``REQ-033``, ``AC-001``,
``50ms`` and ``3.14t`` are single fused tokens that never match
:data:`_BARE_NUMBER_RE` at all.
"""
findings: list[Finding] = []
for line_no, line in enumerate(ctx.lines, start=1):
# Markdown headings are titles, not acceptance criteria — a bare
# number there ("## 6 Detectors") states no magnitude (wave-B
# review: Tier-1 findings on ordinary section headings).
if line.lstrip().startswith("#"):
continue
tokens = list(_TOKEN_RE.finditer(line))
for i, tok_match in enumerate(tokens):
raw = tok_match.group(0)
if i == 0 and raw.endswith("."):
continue
token = _strip_punct(raw)
if not _BARE_NUMBER_RE.match(token):
continue
# Walk back through conjunction chains so the context word
# covers every number it governs: in "ports 8080 and 9090",
# 9090's governing context is still "ports" (likewise
# "steps 3 and 4").
j = i - 1
while j >= 0:
prev_token = _strip_punct(tokens[j].group(0)).lower()
if prev_token in ("and", "or") or _BARE_NUMBER_RE.match(prev_token):
j -= 1
continue
break
else:
prev_token = ""
if prev_token in _ORDINAL_CONTEXT_WORDS:
continue
unit_found = any(
_strip_punct(tokens[j].group(0)).lower() in _UNITS
for j in range(i + 1, min(i + 3, len(tokens)))
)
if unit_found:
continue
findings.append(
Finding.new(
rule_id="missing-unit",
tier=Tier.BLOCKING,
line=line_no,
span=(tok_match.start(), tok_match.end()),
matched_text=raw,
message=f"bare number '{raw}' with no unit within 2 tokens",
context_line=line,
)
)
return findings
_AC_LINE_RE = re.compile(
r"^-\s*\*\*(?P<ref>AC-\d{3}-[A-Za-z0-9]+-\d{2})\*\*:\s*(?P<text>.+)$"
)
_AC_ATTEMPT_RE = re.compile(r"^-\s*\*\*AC")
def _exact_duplicate(ctx: RuleContext) -> list[Finding]:
"""Identical normalized AC text appearing twice (second+ occurrence flagged)."""
findings: list[Finding] = []
seen: dict[str, int] = {}
for line_no, line in enumerate(ctx.lines, start=1):
m = _AC_LINE_RE.match(line.strip())
if not m:
continue
norm = normalize(m.group("text"))
if not norm:
continue
if norm in seen:
findings.append(
Finding.new(
rule_id="exact-duplicate",
tier=Tier.BLOCKING,
line=line_no,
span=(0, len(line)),
matched_text=m.group("text"),
message=(
f"duplicate of line {seen[norm]}: identical normalized AC text"
),
context_line=line,
)
)
else:
seen[norm] = line_no
return findings
def _template_violation(ctx: RuleContext) -> list[Finding]:
"""A line clearly attempting the AC bullet shape but not matching it."""
findings: list[Finding] = []
for line_no, line in enumerate(ctx.lines, start=1):
stripped = line.strip()
if _AC_ATTEMPT_RE.match(stripped) and not _AC_LINE_RE.match(stripped):
findings.append(
Finding.new(
rule_id="template-violation",
tier=Tier.BLOCKING,
line=line_no,
span=(0, len(line)),
matched_text=stripped,
message=(
"AC line does not match the '- **AC-NNN-...-NN**: text' shape"
),
context_line=line,
)
)
return findings
_REF_TOKEN_RE = re.compile(
r"\b(?:REQ-\d{3}-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*|AC-\d{3}-[A-Za-z0-9]+-\d{2}"
r"|ADR-\d{4}|SPEC-\d{3})\b"
)
def _known_ac_req_and_adr_refs(repo_root: Path) -> set[str]:
"""Known ``AC-``/``REQ-``/``ADR-`` refs, reusing the B13 pinning sources."""
refs: set[str] = set()
specs_dir = repo_root / "specs"
adr_dir = repo_root / "docs" / "adr"
refs.update(p.ref for p in AcSource(specs_dir).pinnings())
refs.update(p.ref for p in AdrSource(adr_dir).pinnings())
return refs
def _known_spec_ids(repo_root: Path) -> set[str]:
"""Known ``SPEC-NNN`` ids -- one per ``specs/NNN-*/`` directory."""
specs_dir = repo_root / "specs"
if not specs_dir.exists():
return set()
ids: set[str] = set()
for child in sorted(specs_dir.iterdir()):
if child.is_dir():
num_match = re.match(r"^(\d{3})-", child.name)
if num_match:
ids.add(f"SPEC-{num_match.group(1)}")
return ids
def _incomplete_reference(ctx: RuleContext) -> list[Finding]:
"""A REQ-/AC-/ADR-/SPEC- token that does not resolve against the repo.
Reuses :class:`~..assumptions.sources.AcSource` /
:class:`~..assumptions.sources.AdrSource` (the B13 id-extraction
approach) to build the known-refs pool -- never copies their regexes.
"""
known = _known_ac_req_and_adr_refs(ctx.repo_root) | _known_spec_ids(ctx.repo_root)
findings: list[Finding] = []
for line_no, line in enumerate(ctx.lines, start=1):
for m in _REF_TOKEN_RE.finditer(line):
token = m.group(0)
if token not in known:
findings.append(
Finding.new(
rule_id="incomplete-reference",
tier=Tier.BLOCKING,
line=line_no,
span=(m.start(), m.end()),
matched_text=token,
message=(
f"reference '{token}' does not resolve against the repo"
),
context_line=line,
)
)
return findings
# ---------------------------------------------------------------------------
# Tier 2 -- ADVISORY
# ---------------------------------------------------------------------------
_superlative = _lexicon_rule(
"superlative", Tier.ADVISORY, SUPERLATIVES, "superlative: '{matched}'"
)
# A bare number immediately following the comparative match (its own direct
# object, e.g. "more than 1 second") is not an independent baseline -- it is
# the comparative's own operand. A genuine baseline is either "than <a named
# artifact>" or a number appearing BEFORE the comparative in the segment
# (e.g. "a 20% increase"). This is what makes ADR-0029's own worked example
# testable: "the system takes more than 1 second to respond" has no baseline
# by this definition and DOES fire outside a conditional; wrapped in "if ...",
# global rule 1 exempts it regardless of baseline.
_NAMED_ARTIFACT_AFTER_THAN_RE = re.compile(r"\bthan\s+(?:the\s+)?[A-Z][\w.\-]*")
def _comparative_has_baseline(segment_text: str, match_start: int) -> bool:
if _NAMED_ARTIFACT_AFTER_THAN_RE.search(segment_text):
return True
before = segment_text[:match_start]
return bool(re.search(r"\d", before))
def _comparative_no_baseline(ctx: RuleContext) -> list[Finding]:
"""Comparative lexicon hit with no baseline; global rule 1 exempts conditionals."""
findings: list[Finding] = []
for seg_info in ctx.segments:
if seg_info.segment.kind == "conditional":
continue
text = seg_info.segment.text
for m in find_lexicon_matches(text, COMPARATIVES):
if _comparative_has_baseline(text, m.start()):
continue
findings.append(
_finding_from_segment(
"comparative-no-baseline",
Tier.ADVISORY,
seg_info,
m.start(),
m.end(),
m.group(0),
f"comparative '{m.group(0)}' with no explicit baseline "
"(than X / number / named artifact)",
)
)
return findings
def _negative_statement(ctx: RuleContext) -> list[Finding]:
"""Negation-marker hit; global rule 1 exempts conditional segments."""
findings: list[Finding] = []
for seg_info in ctx.segments:
if seg_info.segment.kind == "conditional":
continue
findings.extend(
_finding_from_segment(
"negative-statement",
Tier.ADVISORY,
seg_info,
m.start(),
m.end(),
m.group(0),
f"negative statement: '{m.group(0)}'",
)
for m in find_lexicon_matches(seg_info.segment.text, NEGATION_MARKERS)
)
return findings
# Small curated verb/modal set for the non-atomic-criterion approximation --
# not exhaustive by design (a starter set, per the module's own convention).
_VERB_TOKENS = frozenset(
{
"shall",
"must",
"will",
"should",
"may",
"can",
"could",
"would",
"validate",
"validates",
"store",
"stores",
"log",
"logs",
"return",
"returns",
"display",
"displays",
"calculate",
"calculates",
"create",
"creates",
"update",
"updates",
"delete",
"deletes",
"send",
"sends",
"receive",
"receives",
"process",
"processes",
"generate",
"generates",
"notify",
"notifies",
"escalate",
"escalates",
"retry",
"retries",
"publish",
"publishes",
"persist",
"persists",
"execute",
"executes",
"register",
"registers",
"dispatch",
"dispatches",
"enqueue",
"enqueues",
"format",
"formats",
"reject",
"rejects",
"accept",
"accepts",
"raise",
"raises",
"emit",
"emits",
}
)
_AND_SPLIT_RE = re.compile(r"\band\b", re.IGNORECASE)
def _has_verb_token(text: str) -> bool:
tokens = re.findall(r"[A-Za-z']+", text.lower())
return any(t in _VERB_TOKENS for t in tokens)
def _non_atomic_criterion(ctx: RuleContext) -> list[Finding]:
"""A main segment joining >=2 distinct verb phrases with 'and'."""
findings: list[Finding] = []
for seg_info in ctx.segments:
if seg_info.segment.kind != "main":
continue
text = seg_info.segment.text
if " and " not in f" {text.lower()} ":
continue
parts = _AND_SPLIT_RE.split(text)
verb_bearing = [p for p in parts if _has_verb_token(p)]
if len(verb_bearing) < 2:
continue
m = _AND_SPLIT_RE.search(text)
if not m:
continue
findings.append(
_finding_from_segment(
"non-atomic-criterion",
Tier.ADVISORY,
seg_info,
m.start(),
m.end(),
m.group(0),
"criterion joins two verb phrases with 'and' -- consider splitting",
)
)
return findings
_OR_SPLIT_RE = re.compile(r"\bor\b", re.IGNORECASE)
def _coordination_ambiguity(ctx: RuleContext) -> list[Finding]:
"""A sentence containing both 'and' and 'or' with no explicit grouping."""
findings: list[Finding] = []
for line_no, sent in sentences(ctx.text):
and_m = _AND_SPLIT_RE.search(sent)
or_m = _OR_SPLIT_RE.search(sent)
if not (and_m and or_m):
continue
line_text = (
ctx.lines[line_no - 1] if 0 <= line_no - 1 < len(ctx.lines) else sent
)
winner = or_m if or_m.start() > and_m.start() else and_m
findings.append(
Finding.new(
rule_id="coordination-ambiguity",
tier=Tier.ADVISORY,
line=line_no,
span=(winner.start(), winner.end()),
matched_text=winner.group(0),
message="sentence combines 'and' and 'or' with no explicit grouping",
context_line=line_text,
)
)
return findings
_solution_language = _lexicon_rule(
"solution-language",
Tier.ADVISORY,
SOLUTION_LANGUAGE_NOUNS,
"solution/implementation language: '{matched}'",
)
# ---------------------------------------------------------------------------
# Tier 3 -- QUESTION-generating only
# ---------------------------------------------------------------------------
_AUX_AFTER_PRONOUN = frozenset(
{
"is",
"are",
"was",
"were",
"has",
"have",
"had",
"will",
"would",
"should",
"must",
"can",
"may",
"might",
"does",
"do",
"shall",
"refers",
"means",
}
)
_WORD_RE = re.compile(r"[A-Za-z']+")
def _vague_pronoun(ctx: RuleContext) -> list[Finding]:
"""A vague pronoun in subject position (first token of a segment)."""
findings: list[Finding] = []
for seg_info in ctx.segments:
text = seg_info.segment.text
tokens = _WORD_RE.findall(text)
if len(tokens) < 2:
continue
first, second = tokens[0].lower(), tokens[1].lower()
if first not in VAGUE_PRONOUNS or second not in _AUX_AFTER_PRONOUN:
continue
m = _WORD_RE.search(text)
if not m:
continue
findings.append(
_finding_from_segment(
"vague-pronoun",
Tier.QUESTION,
seg_info,
m.start(),
m.end(),
m.group(0),
f"vague pronoun '{m.group(0)}' in subject position",
question=f"what does '{m.group(0)}' refer to here?",
)
)
return findings
_QUANTIFIERS = frozenset({"all", "each", "every", "any"})
def _quantifier_scope(ctx: RuleContext) -> list[Finding]:
"""A quantifier and a negation marker co-occurring in one segment."""
findings: list[Finding] = []
for seg_info in ctx.segments:
text = seg_info.segment.text
quant_matches = find_lexicon_matches(text, _QUANTIFIERS)
if not quant_matches:
continue
neg_matches = find_lexicon_matches(text, NEGATION_MARKERS)
if not neg_matches:
continue
q, n = quant_matches[0], neg_matches[0]
start, end = min(q.start(), n.start()), max(q.end(), n.end())
findings.append(
_finding_from_segment(
"quantifier-scope",
Tier.QUESTION,
seg_info,
start,
end,
text[start:end],
f"quantifier '{q.group(0)}' + negation '{n.group(0)}' "
"-- scope is ambiguous",
question=(
f"does '{q.group(0)} ... {n.group(0)}' mean "
"'none' or 'not every one'?"
),
)
)
return findings
_OPERATOR_WORDS = frozenset({"only", "also", "even"})
_COMMON_VERBS_FOR_OPERATOR = frozenset(
{
"log",
"validate",
"allow",
"block",
"return",
"process",
"execute",
"display",
"store",
"delete",
"send",
"receive",
"update",
"check",
"use",
"support",
"enable",
"create",
"notify",
"escalate",
"retry",
"publish",
"persist",
"register",
"dispatch",
"enqueue",
"format",
"accept",
"reject",
"raise",
"emit",
}
)
_NEXT_TOKEN_RE = re.compile(r"[A-Za-z0-9']+")
def _operator_placement(ctx: RuleContext) -> list[Finding]:
"""only/also/even not immediately preceding a verb or a number."""
findings: list[Finding] = []
for seg_info in ctx.segments:
text = seg_info.segment.text
for m in find_lexicon_matches(text, _OPERATOR_WORDS):
after = text[m.end() :].lstrip()
next_m = _NEXT_TOKEN_RE.match(after)
next_tok = next_m.group(0).lower() if next_m else ""
if next_tok in _COMMON_VERBS_FOR_OPERATOR or next_tok.isdigit():
continue
findings.append(
_finding_from_segment(
"operator-placement",
Tier.QUESTION,
seg_info,
m.start(),
m.end(),
m.group(0),
f"operator '{m.group(0)}' not immediately before a verb/number",
question=(
f"does '{m.group(0)}' modify the following word "
"or the whole clause?"
),
)
)
return findings
def _nearby_number(
text: str, match_start: int, match_end: int, window_tokens: int = 3
) -> str | None:
"""A digit-bearing token within ``window_tokens`` of the match, if any."""
tokens = list(_TOKEN_RE.finditer(text))
idx = None
for i, tok in enumerate(tokens):
if (
tok.start() <= match_start < tok.end()
or match_start <= tok.start() < match_end
):
idx = i
break
if idx is None:
return None
lo, hi = max(0, idx - window_tokens), min(len(tokens), idx + window_tokens + 1)
for i in range(lo, hi):
digit_match = re.search(r"\d+", tokens[i].group(0))
if digit_match:
return digit_match.group(0)
return None
def _boundary_word(ctx: RuleContext) -> list[Finding]:
"""A boundary word within a few tokens of a number -- interval ambiguity.
Scoped to number-adjacent hits (the classic "up to 12" case ADR-0029
illustrates); a standalone deadline-word ambiguity with no accompanying
number (e.g. "by Friday" vs. "until Friday") is a real, separate category
left for a future extension -- documented here, not built, to keep this
starter rule's false-positive rate low.
"""
findings: list[Finding] = []
for seg_info in ctx.segments:
text = seg_info.segment.text
for m in find_lexicon_matches(text, BOUNDARY_WORDS):
number = _nearby_number(text, m.start(), m.end())
if number is None:
continue
findings.append(
_finding_from_segment(
"boundary-word",
Tier.QUESTION,
seg_info,
m.start(),
m.end(),
m.group(0),
f"boundary word '{m.group(0)}' near a number -- open or closed?",
question=f"does '{m.group(0)} {number}' include {number}?",
)
)
return findings
_temporal_nonspecific = _lexicon_rule(
"temporal-nonspecific",
Tier.QUESTION,
TEMPORAL_NONSPECIFIC,
"non-specific temporal word: '{matched}'",
question_template="what does '{matched}' mean, numerically?",
)
_deictic_reference = _lexicon_rule(
"deictic-reference",
Tier.QUESTION,
DEICTIC,
"deictic reference: '{matched}'",
question_template="what does '{matched}' refer to?",
)
_SPECIFIER_FOLLOWERS_RE = re.compile(
r"^(returned by|specified in|described in|provided by|captured in|defined in"
r"|associated with|produced by|from the|of the|that|which)\b",
re.IGNORECASE,
)
def _underspecified_head_noun(ctx: RuleContext) -> list[Finding]:
"""'the data'/'the information'/'the content'/'the system' with no defining
qualifier immediately following (heuristic: a following relative/prepositional
clause like "returned by X" or "that Y" resolves it; a bare occurrence does not).
"""
findings: list[Finding] = []
for seg_info in ctx.segments:
text = seg_info.segment.text
for m in find_lexicon_matches(text, UNDERSPECIFIED_HEAD_NOUNS):
after = text[m.end() :].lstrip()
if _SPECIFIER_FOLLOWERS_RE.match(after):
continue
findings.append(
_finding_from_segment(
"underspecified-head-noun",
Tier.QUESTION,
seg_info,
m.start(),
m.end(),
m.group(0),
f"under-specified head noun '{m.group(0)}' "
"with no earlier definition",
question=(
f"what does '{m.group(0)}' refer to -- is it defined earlier?"
),
)
)
return findings
# Shared provenance strings for the registry builders below.
_FEMMER = "ISO/IEC/IEEE 29148 §5.2.7 / Femmer et al. JSS 2017"
_BKK = "Berry, Kamsties & Krieger (2003) §5.2-5.7"
_ISO = "ISO/IEC/IEEE 29148 §5.2.7"
_STRUCTURAL = "SPEC-033 REQ-033-C3 / ADR-0029 (structural)"
def _tier1_dictionary_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
"""The Femmer-family dictionary rules (measured precision) + ISO placeholders."""
return {
"subjective-language": (
RuleMeta("subjective-language", Tier.BLOCKING, _FEMMER, 0.96),
_subjective_language,
),
"ambiguous-adverb-adjective": (
RuleMeta("ambiguous-adverb-adjective", Tier.BLOCKING, _FEMMER, 0.81),
_ambiguous_adverb_adjective,
),
"loophole": (RuleMeta("loophole", Tier.BLOCKING, _FEMMER, 0.72), _loophole),
"open-ended-term": (
RuleMeta("open-ended-term", Tier.BLOCKING, _FEMMER, 0.70),
_open_ended_term,
),
"weak-modal": (RuleMeta("weak-modal", Tier.BLOCKING, _ISO, None), _weak_modal),
"unresolved-placeholder": (
RuleMeta("unresolved-placeholder", Tier.BLOCKING, _ISO, None),
_unresolved_placeholder,
),
}
def _tier1_structural_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
"""The structural (non-dictionary-family) BLOCKING rules."""
return {
"missing-unit": (
RuleMeta("missing-unit", Tier.BLOCKING, _STRUCTURAL, None),
_missing_unit,
),
"exact-duplicate": (
RuleMeta("exact-duplicate", Tier.BLOCKING, _STRUCTURAL, None),
_exact_duplicate,
),
"template-violation": (
RuleMeta("template-violation", Tier.BLOCKING, _STRUCTURAL, None),
_template_violation,
),
"incomplete-reference": (
RuleMeta(
"incomplete-reference",
Tier.BLOCKING,
f"{_STRUCTURAL}, reuses B13 sources.py",
None,
),
_incomplete_reference,
),
}
def _tier1_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
return {**_tier1_dictionary_registry(), **_tier1_structural_registry()}
def _tier2_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
return {
"superlative": (
RuleMeta("superlative", Tier.ADVISORY, _FEMMER, 0.49),
_superlative,
),
"comparative-no-baseline": (
RuleMeta("comparative-no-baseline", Tier.ADVISORY, _FEMMER, 0.48),
_comparative_no_baseline,
),
"negative-statement": (
RuleMeta("negative-statement", Tier.ADVISORY, _FEMMER, 0.33),
_negative_statement,
),
"non-atomic-criterion": (
RuleMeta("non-atomic-criterion", Tier.ADVISORY, _ISO, None),
_non_atomic_criterion,
),
"coordination-ambiguity": (
RuleMeta("coordination-ambiguity", Tier.ADVISORY, _BKK, None),
_coordination_ambiguity,
),
"solution-language": (
RuleMeta("solution-language", Tier.ADVISORY, _ISO, None),
_solution_language,
),
}
def _tier3_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
return {
"vague-pronoun": (
RuleMeta("vague-pronoun", Tier.QUESTION, _FEMMER, 0.26),
_vague_pronoun,
),
"quantifier-scope": (
RuleMeta("quantifier-scope", Tier.QUESTION, _BKK, None),
_quantifier_scope,
),
"operator-placement": (
RuleMeta("operator-placement", Tier.QUESTION, _BKK, None),
_operator_placement,
),
"boundary-word": (
RuleMeta("boundary-word", Tier.QUESTION, _BKK, None),
_boundary_word,
),
"temporal-nonspecific": (
RuleMeta("temporal-nonspecific", Tier.QUESTION, _BKK, None),
_temporal_nonspecific,
),
"underspecified-head-noun": (
RuleMeta("underspecified-head-noun", Tier.QUESTION, _BKK, None),
_underspecified_head_noun,
),
"deictic-reference": (
RuleMeta("deictic-reference", Tier.QUESTION, _BKK, None),
_deictic_reference,
),
}
@traces(spec="033", reqs=["REQ-033-C3"])
def _build_registry() -> dict[str, tuple[RuleMeta, RuleFn]]:
"""Build the rule registry (see :data:`ALL_RULES`) from its three tiers."""
return {**_tier1_registry(), **_tier2_registry(), **_tier3_registry()}
ALL_RULES: dict[str, tuple[RuleMeta, RuleFn]] = _build_registry()