← Files ThoughtfulBits SkillsARCHIVED FILE

skills/test-ui-ux/scripts/aggregate_scores.py

20.5 KB · Oct 5, 2026 · 18:30 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate and aggregate the five test-ui-ux evaluator results."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, NoReturn


METHODS = (
    "spark",
    "nielsen",
    "cognitive_walkthrough",
    "pure",
    "wcag_2_2_aa",
)
SOURCE_KINDS = {"live_url", "screenshot", "spec", "mixed"}
CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2}
SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
THRESHOLD = 8.0
IMPORTANCE = {"primary", "supporting", "recovery_safety"}
EVIDENCE_STATUS = {"observed", "specified", "visible_only", "inferred", "blocked"}
INTERACTIONS = {
    "click_tap",
    "field_entry",
    "selection",
    "keyboard",
    "navigation",
    "system_wait",
}
CURRENT_REQUIREMENTS = {"required", "optional"}
INPUT_RECOMMENDATIONS = {
    "keep_user_required",
    "make_optional",
    "prefill_known_data",
    "derive_automatically",
    "ai_draft_confirm",
    "remove",
}
COUNT_FIELDS = (
    "logical_steps",
    "clicks_taps",
    "required_fields",
    "optional_fields",
)


def fail(message: str) -> NoReturn:
    raise ValueError(message)


def require_object(value: Any, label: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        fail(f"{label} must be an object")
    return value


def require_list(value: Any, label: str) -> list[Any]:
    if not isinstance(value, list):
        fail(f"{label} must be an array")
    return value


def require_string(value: Any, label: str) -> str:
    if not isinstance(value, str) or not value.strip():
        fail(f"{label} must be a non-empty string")
    return value


def require_boolean(value: Any, label: str) -> bool:
    if not isinstance(value, bool):
        fail(f"{label} must be a boolean")
    return value


def require_nonnegative_integer(value: Any, label: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        fail(f"{label} must be a nonnegative integer")
    return value


def validate_counts(raw: Any, label: str, *, require_known: bool) -> dict[str, Any]:
    counts = require_object(raw, label)
    for field in COUNT_FIELDS:
        value = counts.get(field)
        if value is None:
            if require_known:
                fail(f"{label}.{field} must be known for observed or specified actions")
            continue
        require_nonnegative_integer(value, f"{label}.{field}")
    return counts


def validate_action_analysis(raw: Any, label: str) -> dict[str, Any]:
    analysis = require_object(raw, label)
    raw_screens = require_list(analysis.get("screens"), f"{label}.screens")
    raw_actions = require_list(analysis.get("actions"), f"{label}.actions")
    if not raw_screens:
        fail(f"{label}.screens must contain at least one item")
    if not raw_actions:
        fail(f"{label}.actions must contain at least one item")

    screen_ids: set[str] = set()
    action_refs: set[str] = set()
    screens: list[dict[str, Any]] = []
    for screen_index, raw_screen in enumerate(raw_screens):
        screen_label = f"{label}.screens[{screen_index}]"
        screen = require_object(raw_screen, screen_label)
        screen_id = require_string(screen.get("id"), f"{screen_label}.id")
        if screen_id in screen_ids:
            fail(f"{label}.screens contains duplicate id {screen_id!r}")
        screen_ids.add(screen_id)
        require_string(screen.get("evidence_ref"), f"{screen_label}.evidence_ref")
        require_string(screen.get("purpose"), f"{screen_label}.purpose")
        important_action_ids = require_list(
            screen.get("important_action_ids"), f"{screen_label}.important_action_ids"
        )
        if not important_action_ids:
            fail(f"{screen_label}.important_action_ids must contain at least one item")
        local_refs: set[str] = set()
        for ref_index, raw_ref in enumerate(important_action_ids):
            ref = require_string(
                raw_ref, f"{screen_label}.important_action_ids[{ref_index}]"
            )
            if ref in local_refs:
                fail(f"{screen_label}.important_action_ids contains duplicate {ref!r}")
            local_refs.add(ref)
            action_refs.add(ref)
        screens.append(screen)

    action_ids: set[str] = set()
    actions: list[dict[str, Any]] = []
    for action_index, raw_action in enumerate(raw_actions):
        action_label = f"{label}.actions[{action_index}]"
        action = require_object(raw_action, action_label)
        action_id = require_string(action.get("id"), f"{action_label}.id")
        if action_id in action_ids:
            fail(f"{label}.actions contains duplicate id {action_id!r}")
        action_ids.add(action_id)
        require_string(action.get("label"), f"{action_label}.label")
        importance = require_string(action.get("importance"), f"{action_label}.importance")
        if importance not in IMPORTANCE:
            fail(f"{action_label}.importance must be primary, supporting, or recovery_safety")
        require_string(action.get("outcome"), f"{action_label}.outcome")
        evidence_status = require_string(
            action.get("evidence_status"), f"{action_label}.evidence_status"
        )
        if evidence_status not in EVIDENCE_STATUS:
            fail(
                f"{action_label}.evidence_status must be observed, specified, "
                "visible_only, inferred, or blocked"
            )

        raw_steps = require_list(action.get("steps"), f"{action_label}.steps")
        if not raw_steps:
            fail(f"{action_label}.steps must contain at least one item")
        user_step_count = 0
        pointer_activations = 0
        for step_index, raw_step in enumerate(raw_steps):
            step_label = f"{action_label}.steps[{step_index}]"
            step = require_object(raw_step, step_label)
            order = step.get("order")
            if (
                isinstance(order, bool)
                or not isinstance(order, int)
                or order != step_index + 1
            ):
                fail(f"{step_label}.order must be {step_index + 1}")
            step_screen_id = require_string(
                step.get("screen_id"), f"{step_label}.screen_id"
            )
            if step_screen_id not in screen_ids:
                fail(f"{step_label}.screen_id references unknown screen {step_screen_id!r}")
            require_string(step.get("description"), f"{step_label}.description")
            interaction = require_string(
                step.get("interaction"), f"{step_label}.interaction"
            )
            if interaction not in INTERACTIONS:
                fail(
                    f"{step_label}.interaction must be click_tap, field_entry, "
                    "selection, keyboard, navigation, or system_wait"
                )
            if interaction != "system_wait":
                user_step_count += 1
            pointer_activations += require_nonnegative_integer(
                step.get("pointer_activations"), f"{step_label}.pointer_activations"
            )
            difficulty = step.get("difficulty")
            if (
                isinstance(difficulty, bool)
                or not isinstance(difficulty, int)
                or difficulty not in {1, 2, 3}
            ):
                fail(f"{step_label}.difficulty must be 1, 2, or 3")
            require_boolean(step.get("avoidable"), f"{step_label}.avoidable")

        raw_inputs = require_list(action.get("inputs"), f"{action_label}.inputs")
        required_fields = 0
        optional_fields = 0
        for input_index, raw_input in enumerate(raw_inputs):
            input_label = f"{action_label}.inputs[{input_index}]"
            input_item = require_object(raw_input, input_label)
            require_string(input_item.get("name"), f"{input_label}.name")
            current_requirement = require_string(
                input_item.get("current_requirement"),
                f"{input_label}.current_requirement",
            )
            if current_requirement not in CURRENT_REQUIREMENTS:
                fail(f"{input_label}.current_requirement must be required or optional")
            if current_requirement == "required":
                required_fields += 1
            else:
                optional_fields += 1
            recommendation = require_string(
                input_item.get("recommendation"), f"{input_label}.recommendation"
            )
            if recommendation not in INPUT_RECOMMENDATIONS:
                fail(
                    f"{input_label}.recommendation must be keep_user_required, "
                    "make_optional, prefill_known_data, derive_automatically, "
                    "ai_draft_confirm, or remove"
                )
            confirmation_required = require_boolean(
                input_item.get("confirmation_required"),
                f"{input_label}.confirmation_required",
            )
            sensitive = require_boolean(
                input_item.get("sensitive_or_consequential"),
                f"{input_label}.sensitive_or_consequential",
            )
            if recommendation == "ai_draft_confirm" and not confirmation_required:
                fail(f"{input_label} AI drafts must require confirmation")
            if recommendation == "ai_draft_confirm" and sensitive:
                fail(f"{input_label} sensitive or consequential input cannot use an AI draft")
            require_string(input_item.get("rationale"), f"{input_label}.rationale")

        require_known = evidence_status in {"observed", "specified"}
        current_counts = validate_counts(
            action.get("current_counts"),
            f"{action_label}.current_counts",
            require_known=require_known,
        )
        if all(current_counts.get(field) is not None for field in COUNT_FIELDS):
            if current_counts["logical_steps"] != user_step_count:
                fail(
                    f"{action_label}.current_counts.logical_steps must equal "
                    "the number of non-wait steps"
                )
            if current_counts["clicks_taps"] != pointer_activations:
                fail(
                    f"{action_label}.current_counts.clicks_taps must equal "
                    "summed pointer activations"
                )
            if current_counts["required_fields"] != required_fields:
                fail(
                    f"{action_label}.current_counts.required_fields must equal "
                    "the number of required inputs"
                )
            if current_counts["optional_fields"] != optional_fields:
                fail(
                    f"{action_label}.current_counts.optional_fields must equal "
                    "the number of optional inputs"
                )

        path_label = f"{action_label}.simplest_safe_path"
        simplest_safe_path = require_object(action.get("simplest_safe_path"), path_label)
        validate_counts(simplest_safe_path, path_label, require_known=False)
        for field in ("changes", "safeguards"):
            values = require_list(simplest_safe_path.get(field), f"{path_label}.{field}")
            if not values:
                fail(f"{path_label}.{field} must contain at least one item")
            for value_index, value in enumerate(values):
                require_string(value, f"{path_label}.{field}[{value_index}]")
        actions.append(action)

    unknown_action_refs = sorted(action_refs - action_ids)
    if unknown_action_refs:
        fail(f"{label}.screens reference unknown actions {unknown_action_refs}")
    unlisted_actions = sorted(action_ids - action_refs)
    if unlisted_actions:
        fail(f"{label}.actions are not listed on any screen {unlisted_actions}")

    return {"screens": screens, "actions": actions}


def validate_method(raw: Any, index: int) -> dict[str, Any]:
    method = require_object(raw, f"methods[{index}]")
    name = require_string(method.get("method"), f"methods[{index}].method")
    if name not in METHODS:
        fail(f"methods[{index}].method has unsupported value {name!r}")

    score = method.get("score")
    if isinstance(score, bool) or not isinstance(score, int) or not 1 <= score <= 10:
        fail(f"methods[{index}].score must be an integer from 1 through 10")

    confidence = require_string(
        method.get("confidence"), f"methods[{index}].confidence"
    )
    if confidence not in CONFIDENCE_RANK:
        fail(f"methods[{index}].confidence must be high, medium, or low")

    evidence = require_list(method.get("evidence"), f"methods[{index}].evidence")
    if not evidence:
        fail(f"methods[{index}].evidence must contain at least one item")
    for evidence_index, raw_item in enumerate(evidence):
        item = require_object(
            raw_item, f"methods[{index}].evidence[{evidence_index}]"
        )
        require_string(
            item.get("ref"), f"methods[{index}].evidence[{evidence_index}].ref"
        )
        require_string(
            item.get("claim"),
            f"methods[{index}].evidence[{evidence_index}].claim",
        )

    findings = require_list(method.get("findings"), f"methods[{index}].findings")
    for finding_index, raw_finding in enumerate(findings):
        finding = require_object(
            raw_finding, f"methods[{index}].findings[{finding_index}]"
        )
        severity = require_string(
            finding.get("severity"),
            f"methods[{index}].findings[{finding_index}].severity",
        )
        if severity not in SEVERITY_RANK:
            fail(
                f"methods[{index}].findings[{finding_index}].severity "
                "must be critical, high, medium, or low"
            )
        for field in ("title", "evidence_ref", "recommendation"):
            require_string(
                finding.get(field),
                f"methods[{index}].findings[{finding_index}].{field}",
            )

    critical_failures = require_list(
        method.get("critical_failures"), f"methods[{index}].critical_failures"
    )
    for critical_index, critical in enumerate(critical_failures):
        require_string(
            critical,
            f"methods[{index}].critical_failures[{critical_index}]",
        )

    limitations = require_list(
        method.get("limitations"), f"methods[{index}].limitations"
    )
    for limitation_index, limitation in enumerate(limitations):
        require_string(
            limitation, f"methods[{index}].limitations[{limitation_index}]"
        )

    methodology_data = require_object(
        method.get("methodology_data"), f"methods[{index}].methodology_data"
    )
    action_analysis = methodology_data.get("action_analysis")
    if name == "pure":
        validate_action_analysis(
            action_analysis,
            f"methods[{index}].methodology_data.action_analysis",
        )
    elif action_analysis is not None:
        fail(f"methods[{index}].methodology_data.action_analysis is allowed only for pure")
    return method


def validate_input(raw: Any) -> dict[str, Any]:
    data = require_object(raw, "input")
    if data.get("schema_version") != "2.0":
        fail("schema_version must be '2.0'")

    run_id = require_string(data.get("run_id"), "run_id")
    context = require_object(data.get("context"), "context")
    for field in ("target", "test_brief", "target_user", "viewport", "build_identity"):
        require_string(context.get(field), f"context.{field}")
    source_kind = require_string(context.get("source_kind"), "context.source_kind")
    if source_kind not in SOURCE_KINDS:
        fail("context.source_kind must be live_url, screenshot, spec, or mixed")
    tasks = require_list(context.get("tasks"), "context.tasks")
    if not tasks:
        fail("context.tasks must contain at least one task")
    for task_index, task in enumerate(tasks):
        require_string(task, f"context.tasks[{task_index}]")
    require_list(context.get("constraints"), "context.constraints")

    interactive = data.get("interactive_test_completed")
    comparable = data.get("comparable_to_previous")
    if not isinstance(interactive, bool):
        fail("interactive_test_completed must be a boolean")
    if not isinstance(comparable, bool):
        fail("comparable_to_previous must be a boolean")
    if interactive and source_kind not in {"live_url", "mixed"}:
        fail("interactive_test_completed can be true only for live_url or mixed input")

    raw_methods = require_list(data.get("methods"), "methods")
    if len(raw_methods) != len(METHODS):
        fail(f"methods must contain exactly {len(METHODS)} evaluator results")
    methods = [validate_method(method, index) for index, method in enumerate(raw_methods)]
    names = [method["method"] for method in methods]
    if len(set(names)) != len(names):
        fail("methods contains duplicate method identifiers")
    missing = sorted(set(METHODS) - set(names))
    extra = sorted(set(names) - set(METHODS))
    if missing or extra:
        fail(f"methods mismatch: missing={missing}, extra={extra}")

    return {
        "schema_version": "2.0",
        "run_id": run_id,
        "context": context,
        "interactive_test_completed": interactive,
        "comparable_to_previous": comparable,
        "methods": methods,
    }


def aggregate(data: dict[str, Any]) -> dict[str, Any]:
    methods_by_name = {method["method"]: method for method in data["methods"]}
    ordered_methods = [methods_by_name[name] for name in METHODS]
    scores = {method["method"]: method["score"] for method in ordered_methods}
    average = round(sum(scores.values()) / len(METHODS), 1)

    critical_failures = []
    for method in ordered_methods:
        for failure in method["critical_failures"]:
            critical_failures.append({"method": method["method"], "failure": failure})

    threshold_met = average >= THRESHOLD
    if critical_failures:
        verdict = "fail"
    elif not data["interactive_test_completed"]:
        verdict = "provisional"
    elif threshold_met:
        verdict = "pass"
    else:
        verdict = "fail"

    confidence = min(
        (method["confidence"] for method in ordered_methods),
        key=CONFIDENCE_RANK.__getitem__,
    )

    action_analysis = methods_by_name["pure"]["methodology_data"]["action_analysis"]

    ranked_findings = []
    for method_order, method in enumerate(ordered_methods):
        for finding_order, finding in enumerate(method["findings"]):
            ranked_findings.append(
                (
                    SEVERITY_RANK[finding["severity"]],
                    method_order,
                    finding_order,
                    method["method"],
                    finding,
                )
            )
    ranked_findings.sort(key=lambda item: item[:3])

    top_fixes = []
    seen_titles = set()
    for _, _, _, method_name, finding in ranked_findings:
        normalized_title = finding["title"].casefold().strip()
        if normalized_title in seen_titles:
            continue
        seen_titles.add(normalized_title)
        top_fixes.append(
            {
                "method": method_name,
                "severity": finding["severity"],
                "title": finding["title"],
                "evidence_ref": finding["evidence_ref"],
                "recommendation": finding["recommendation"],
            }
        )
        if len(top_fixes) == 3:
            break

    return {
        "schema_version": "2.0",
        "run_id": data["run_id"],
        "context": data["context"],
        "interactive_test_completed": data["interactive_test_completed"],
        "comparable_to_previous": data["comparable_to_previous"],
        "method_scores": scores,
        "average_score": average,
        "threshold": THRESHOLD,
        "threshold_met": threshold_met,
        "critical_failures": critical_failures,
        "verdict": verdict,
        "confidence": confidence,
        "action_analysis": action_analysis,
        "top_fixes": top_fixes,
        "methods": ordered_methods,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Validate and aggregate five test-ui-ux evaluator results."
    )
    parser.add_argument("input", help="Input JSON file, or - for standard input")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        if args.input == "-":
            raw = json.load(sys.stdin)
        else:
            with Path(args.input).open(encoding="utf-8") as input_file:
                raw = json.load(input_file)
        output = aggregate(validate_input(raw))
    except (OSError, json.JSONDecodeError, ValueError) as error:
        print(json.dumps({"error": str(error)}, sort_keys=True), file=sys.stderr)
        return 2

    json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 429d1f346831d4819cddb1792a0937162bdbd7a1a794d70d0059300870c237f0