X-S1-01 Blueprint check

Field Value
Purpose Check a weighted blueprint JSON file for the errors and warnings described below, and print the domain by cognitive level cross-tab.
Usage python3 scripts/s1/blueprint_check.py –help In a shell: python3 scripts/s1/blueprint_check.py BLUEPRINT.json [–levels a,b,c] [–json]
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A blueprint JSON file: schema_version, program, optional bank_size, optional difficulty_split, and a list of domains. Each domain has id, name, weight and a list of objectives. Each objective has id, text, bloom (a cognitive level), and optional tier, chapter and supported_by.
Outputs One line per error or warning, the domain by cognitive level cross-tab, and a summary line, all printed to standard output. With –json, one JSON object instead.
Used in S1.3 Draft the blueprint
Source scripts/s1/blueprint_check.py

Source code

"""
ID: X-S1-01
Title: Blueprint check
Stage: S1
Purpose: Check a weighted blueprint JSON file for the errors and warnings
    described below, and print the domain by cognitive level cross-tab.
Usage: python3 scripts/s1/blueprint_check.py --help
    In a shell: python3 scripts/s1/blueprint_check.py BLUEPRINT.json
    [--levels a,b,c] [--json]
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A blueprint JSON file: schema_version, program, optional bank_size,
    optional difficulty_split, and a list of domains. Each domain has id,
    name, weight and a list of objectives. Each objective has id, text,
    bloom (a cognitive level), and optional tier, chapter and supported_by.
Outputs: One line per error or warning, the domain by cognitive level
    cross-tab, and a summary line, all printed to standard output. With
    --json, one JSON object instead.

This script checks structure and arithmetic only. It never reads
`supported_by`, so an empty list there (a coverage gap for a later
sub-stage) is not an error here.

Errors (exit 1): domain weights that do not sum to 100; a domain missing
`id`, `name` or `weight`; an objective missing `id`, `text` or `bloom`; a
domain or objective entry that is not a JSON object; a `bloom` value
outside the cognitive-level list; a `tier` present but outside 1 to 4; a
domain with no objectives; a domain id or an objective id used more than
once.

Warnings (exit 0, never change the exit code): an objective with no
`tier` (tiers are filled in later, at sub-stage S1.7); a domain whose
`weight`, multiplied by `bank_size` and divided by 100, is not a whole
number; a domain with no objective at the cognitive level "apply" or
above (skipped when "apply" is not in the active level list).

The cognitive-level list defaults to remember, understand, apply,
analyze, evaluate, create; `--levels` replaces it with a comma-separated
list in the order you give.
"""

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

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "blueprint_check.py: this script needs Python 3.10 or newer, but "
        f"this is {sys.version_info.major}.{sys.version_info.minor}. "
        "Run it with a newer python3.",
        file=sys.stderr,
    )
    sys.exit(2)

DEFAULT_LEVELS = ("remember", "understand", "apply", "analyze", "evaluate", "create")
REQUIRED_DOMAIN_FIELDS = ("id", "name", "weight")
REQUIRED_OBJECTIVE_FIELDS = ("id", "text", "bloom")
TIER_MIN, TIER_MAX = 1, 4
MAX_BYTES = 5_000_000
CrossTab = dict[str, dict[str, int]]


def parse_levels(raw: str | None) -> tuple[str, ...]:
    """Return the cognitive-level list: --levels, comma-separated, or the default."""
    if raw is None:
        return DEFAULT_LEVELS
    names = [part.strip() for part in raw.split(",")]
    if not all(names):
        raise ValueError("--levels holds an empty level name")
    if len(set(names)) != len(names):
        raise ValueError("--levels holds a repeated level name")
    return tuple(names)


def load_blueprint(path: Path) -> Any:
    """Read and parse the blueprint file; never follows a symlink."""
    if path.is_symlink():
        raise OSError(f"refusing to read a symlink: {path}")
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError(f"{path} is over {MAX_BYTES} bytes; skipping")
    text = path.read_text(encoding="utf-8", errors="replace")
    return json.loads(text)


def _is_number(value: Any) -> TypeGuard[float]:
    """True for an int or float, but not a bool (bool is a subclass of int)."""
    return isinstance(value, (int, float)) and not isinstance(value, bool)


def check_blueprint(
    data: Any, levels: tuple[str, ...]
) -> tuple[list[str], list[str], CrossTab, int, int]:
    """Return (errors, warnings, cross_tab, domain_count, objective_count)."""
    if not isinstance(data, dict):
        raise ValueError("the blueprint is not a JSON object")
    domains = data.get("domains")
    if not isinstance(domains, list):
        raise ValueError("'domains' is missing or is not a JSON list")

    errors: list[str] = []
    warnings: list[str] = []
    cross_tab: CrossTab = {}
    domain_ids: dict[str, bool] = {}
    objective_ids: dict[str, bool] = {}
    objective_count = 0
    weight_total = 0.0
    bank_size = data.get("bank_size")
    apply_index = levels.index("apply") if "apply" in levels else None

    for position, domain in enumerate(domains, start=1):
        label = f"domain #{position}"
        if not isinstance(domain, dict):
            errors.append(f"bad-domain: {label} is not a JSON object")
            continue
        domain_id = domain.get("id")
        if isinstance(domain_id, str) and domain_id:
            label = f"domain {domain_id}"
            if domain_id in domain_ids:
                errors.append(f"duplicate-id: domain id '{domain_id}' is used twice")
            else:
                domain_ids[domain_id] = True
        for field_name in REQUIRED_DOMAIN_FIELDS:
            if field_name not in domain:
                errors.append(f"missing-field: {label} has no '{field_name}'")
        weight = domain.get("weight")
        if _is_number(weight):
            weight_total += weight

        objectives = domain.get("objectives")
        if not isinstance(objectives, list) or not objectives:
            errors.append(f"empty-domain: {label} has no objectives")
            objectives = []

        counts = {level: 0 for level in levels}
        missing_tier = False
        has_apply_plus = False
        for opos, objective in enumerate(objectives, start=1):
            objective_count += 1
            olabel = f"{label} objective #{opos}"
            if not isinstance(objective, dict):
                errors.append(f"bad-objective: {olabel} is not a JSON object")
                continue
            objective_id = objective.get("id")
            if isinstance(objective_id, str) and objective_id:
                olabel = f"objective {objective_id}"
                if objective_id in objective_ids:
                    msg = f"duplicate-id: objective id '{objective_id}' is used twice"
                    errors.append(msg)
                else:
                    objective_ids[objective_id] = True
            for field_name in REQUIRED_OBJECTIVE_FIELDS:
                if field_name not in objective:
                    errors.append(f"missing-field: {olabel} has no '{field_name}'")
            if "bloom" in objective:
                bloom = objective["bloom"]
                if bloom not in levels:
                    shown = ", ".join(levels)
                    msg = f"bad-bloom: {olabel} has bloom {bloom!r}, not in {shown}"
                    errors.append(msg)
                else:
                    counts[bloom] += 1
                    if apply_index is not None and levels.index(bloom) >= apply_index:
                        has_apply_plus = True
            if "tier" in objective:
                tier = objective["tier"]
                if not (_is_number(tier) and TIER_MIN <= tier <= TIER_MAX):
                    msg = (
                        f"bad-tier: {olabel} has tier {tier!r}, "
                        f"must be {TIER_MIN} to {TIER_MAX}"
                    )
                    errors.append(msg)
            else:
                missing_tier = True

        if missing_tier and objectives:
            msg = f"missing-tier: {label} has an objective with no tier"
            warnings.append(msg + " (provisional until sub-stage S1.7)")
        if apply_index is not None and objectives and not has_apply_plus:
            warnings.append(
                f"no-apply-plus: {label} has no objective at apply or above"
            )
        if _is_number(bank_size) and _is_number(weight):
            product = bank_size * weight / 100
            if abs(product - round(product)) > 1e-9:
                msg = (
                    f"bank-size: {label} weight {weight!r} gives "
                    f"bank_size * weight / 100 = {product!r}, not a whole number"
                )
                warnings.append(msg)

        key = domain_id if isinstance(domain_id, str) and domain_id else label
        cross_tab[key] = counts

    if abs(weight_total - 100) > 1e-9:
        shown_total = (
            int(weight_total) if weight_total == int(weight_total) else weight_total
        )
        errors.append(f"weight-sum: domain weights sum to {shown_total}, not 100")

    return errors, warnings, cross_tab, len(domains), objective_count


def cross_tab_lines(cross_tab: CrossTab, levels: tuple[str, ...]) -> list[str]:
    """One line per domain: 'ID  level=count level=count ...', dash for zero."""
    lines = []
    for domain_key, counts in cross_tab.items():
        cells = " ".join(
            f"{level}={counts[level] if counts[level] else '-'}" for level in levels
        )
        lines.append(f"{domain_key}  {cells}")
    return lines


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="blueprint_check.py",
        description=(
            "Check a blueprint JSON file's weights, ids and cognitive levels, "
            "and print the domain by cognitive level cross-tab. Writes no files."
        ),
    )
    parser.add_argument(
        "blueprint", metavar="BLUEPRINT", help="path to the blueprint JSON"
    )
    parser.add_argument(
        "--levels",
        metavar="a,b,c",
        help="comma-separated cognitive levels, in order (default: the six-level "
        "scale)",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="print one JSON object instead of text lines",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    """Command line entry point; prints the report, returns an exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        levels = parse_levels(args.levels)
        data = load_blueprint(Path(args.blueprint))
        errors, warnings, cross_tab, domain_count, objective_count = check_blueprint(
            data, levels
        )
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"blueprint_check.py: error: {exc}", file=sys.stderr)
        return 2
    if args.json:
        payload = {
            "domains": domain_count,
            "objectives": objective_count,
            "levels": list(levels),
            "errors": errors,
            "warnings": warnings,
            "cross_tab": cross_tab,
        }
        print(json.dumps(payload, indent=2, sort_keys=True))
    else:
        for message in errors:
            print(f"error {message}")
        for message in warnings:
            print(f"warning {message}")
        print("domain by cognitive level (dash means no objective at that level):")
        for line in cross_tab_lines(cross_tab, levels):
            print(line)
        print(
            f"domains={domain_count} objectives={objective_count} "
            f"errors={len(errors)} warnings={len(warnings)}"
        )
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

To the extent possible under law, copyright and related rights in this work are waived under CC0 1.0 Universal.

This site uses Just the Docs, a documentation theme for Jekyll.