| """Automated validation gate for Iris spiral sharpness.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from difflib import SequenceMatcher |
| import re |
|
|
| from iris.engine import ( |
| WEAK_CENTER_FILLS, |
| advice_language_phrase, |
| ) |
| from iris.spiral import SpiralRun |
|
|
|
|
| SIMILARITY_REPEAT_THRESHOLD = 0.72 |
| SIMILARITY_SEPARATION_THRESHOLD = 0.68 |
|
|
|
|
| @dataclass(frozen=True) |
| class CriterionScore: |
| name: str |
| passed: int |
| total: int |
| detail: str |
|
|
| @property |
| def ok(self) -> bool: |
| return self.passed == self.total |
|
|
|
|
| @dataclass(frozen=True) |
| class GateReport: |
| idea: str |
| criteria: list[CriterionScore] |
|
|
| @property |
| def ok(self) -> bool: |
| return all(criterion.ok for criterion in self.criteria) |
|
|
| @property |
| def passed(self) -> int: |
| return sum(1 for criterion in self.criteria if criterion.ok) |
|
|
| @property |
| def total(self) -> int: |
| return len(self.criteria) |
|
|
|
|
| def score_spiral(run: SpiralRun) -> GateReport: |
| criteria = [ |
| _score_ring_separation(run), |
| _score_no_advice_language(run), |
| _score_concrete_nouns(run), |
| _score_existing_alternative(run), |
| _score_no_repeated_pressure(run), |
| _score_concrete_center(run), |
| ] |
| return GateReport(idea=run.idea, criteria=criteria) |
|
|
|
|
| def _score_ring_separation(run: SpiralRun) -> CriterionScore: |
| pairs = _pressure_pairs(run) |
| if not pairs: |
| return CriterionScore("ring_separation", 1, 1, "single ring") |
|
|
| separated = 0 |
| closest = 0.0 |
| for left, right in pairs: |
| similarity = _similarity(left, right) |
| closest = max(closest, similarity) |
| if similarity < SIMILARITY_SEPARATION_THRESHOLD: |
| separated += 1 |
|
|
| return CriterionScore( |
| "ring_separation", |
| separated, |
| len(pairs), |
| f"closest similarity {closest:.2f}", |
| ) |
|
|
|
|
| def _score_no_advice_language(run: SpiralRun) -> CriterionScore: |
| fields = [pressure.why_it_bites for pressure in run.pressures] |
| fields.extend( |
| [ |
| run.center.actor, |
| run.center.situation, |
| run.center.assumption_to_test, |
| ] |
| ) |
| passed = 0 |
| offenders: list[str] = [] |
| for field in fields: |
| phrase = advice_language_phrase(field) |
| if phrase is None: |
| passed += 1 |
| else: |
| offenders.append(phrase) |
|
|
| detail = "clean" if not offenders else "offenders: " + ", ".join(offenders[:4]) |
| return CriterionScore("no_advice_language", passed, len(fields), detail) |
|
|
|
|
| def _score_concrete_nouns(run: SpiralRun) -> CriterionScore: |
| keywords = _keywords(run.idea) |
| if not keywords: |
| return CriterionScore("concrete_nouns_present", 1, 1, "no idea keywords") |
|
|
| fields = [pressure.pressure for pressure in run.pressures] |
| fields.append( |
| " ".join( |
| ( |
| run.center.actor, |
| run.center.situation, |
| run.center.assumption_to_test, |
| ) |
| ) |
| ) |
| passed = sum(1 for field in fields if _has_keyword_match(field, keywords)) |
| detail = "idea keywords: " + ", ".join(sorted(keywords)[:6]) |
| return CriterionScore("concrete_nouns_present", passed, len(fields), detail) |
|
|
|
|
| def _score_no_repeated_pressure(run: SpiralRun) -> CriterionScore: |
| pairs = _pressure_pairs(run) |
| if not pairs: |
| return CriterionScore("no_repeated_pressure", 1, 1, "single ring") |
|
|
| distinct = 0 |
| closest = 0.0 |
| for left, right in pairs: |
| similarity = _similarity(left, right) |
| closest = max(closest, similarity) |
| if similarity < SIMILARITY_REPEAT_THRESHOLD: |
| distinct += 1 |
|
|
| return CriterionScore( |
| "no_repeated_pressure", |
| distinct, |
| len(pairs), |
| f"closest similarity {closest:.2f}", |
| ) |
|
|
|
|
| def _score_existing_alternative(run: SpiralRun) -> CriterionScore: |
| if len(run.pressures) < 3: |
| return CriterionScore("existing_alternative_named", 1, 1, "no Ring 3") |
|
|
| ring_three = run.pressures[2] |
| checks = [ |
| bool(ring_three.alternative), |
| _field_is_concrete(ring_three.alternative or "", minimum_words=1), |
| advice_language_phrase(ring_three.alternative or "") is None, |
| not _alternative_matches_prior( |
| ring_three.alternative or "", |
| [pressure.pressure for pressure in run.pressures[:2]], |
| ), |
| ] |
| passed = sum(1 for check in checks if check) |
| detail = ( |
| f"alternative: {ring_three.alternative}" |
| if ring_three.alternative |
| else "missing alternative" |
| ) |
| return CriterionScore( |
| "existing_alternative_named", |
| passed, |
| len(checks), |
| detail, |
| ) |
|
|
|
|
| def _score_concrete_center(run: SpiralRun) -> CriterionScore: |
| checks = [ |
| _field_is_concrete(run.center.actor, minimum_words=1), |
| _field_is_concrete(run.center.situation, minimum_words=4), |
| _field_is_concrete(run.center.assumption_to_test, minimum_words=5), |
| len(run.center.next_step.split()) >= 12, |
| _has_keyword_match( |
| " ".join( |
| ( |
| run.center.actor, |
| run.center.situation, |
| run.center.assumption_to_test, |
| run.center.next_step, |
| ) |
| ), |
| _keywords(run.idea), |
| ), |
| ] |
| passed = sum(1 for check in checks if check) |
| return CriterionScore( |
| "concrete_center", |
| passed, |
| len(checks), |
| "actor, situation, assumption, formatted action, idea grounding", |
| ) |
|
|
|
|
| def _field_is_concrete(value: str, minimum_words: int) -> bool: |
| normalized = _normalize(value) |
| return normalized not in WEAK_CENTER_FILLS and len(value.split()) >= minimum_words |
|
|
|
|
| def _pressure_pairs(run: SpiralRun) -> list[tuple[str, str]]: |
| pressures = [pressure.pressure for pressure in run.pressures] |
| return [ |
| (left, right) |
| for index, left in enumerate(pressures) |
| for right in pressures[index + 1 :] |
| ] |
|
|
|
|
| def _similarity(left: str, right: str) -> float: |
| return SequenceMatcher(None, _normalize(left), _normalize(right)).ratio() |
|
|
|
|
| def _alternative_matches_prior(alternative: str, prior_pressures: list[str]) -> bool: |
| normalized_alternative = _normalize(alternative) |
| return bool(normalized_alternative) and any( |
| normalized_alternative in _normalize(pressure) for pressure in prior_pressures |
| ) |
|
|
|
|
| def _keywords(text: str) -> set[str]: |
| stop_words = { |
| "about", |
| "actually", |
| "after", |
| "against", |
| "already", |
| "between", |
| "could", |
| "does", |
| "from", |
| "have", |
| "into", |
| "that", |
| "their", |
| "them", |
| "this", |
| "turns", |
| "what", |
| "when", |
| "where", |
| "which", |
| "while", |
| "with", |
| } |
| words = set(re.findall(r"[a-z][a-z0-9]{4,}", text.lower())) |
| return {word for word in words if word not in stop_words} |
|
|
|
|
| def _has_keyword_match(text: str, keywords: set[str]) -> bool: |
| if not keywords: |
| return True |
| normalized_text = _normalize(text) |
| for keyword in keywords: |
| variants = {keyword} |
| if keyword.endswith("s"): |
| variants.add(keyword[:-1]) |
| if keyword.endswith("ies"): |
| variants.add(f"{keyword[:-3]}y") |
| if any(variant and variant in normalized_text for variant in variants): |
| return True |
| return False |
|
|
|
|
| def _normalize(text: str) -> str: |
| return re.sub(r"\s+", " ", text.lower()).strip() |
|
|