import json import sys from pathlib import Path MODEL_REQUIRED_FIELDS = [ "model_id", "model_name", "model_type", "pipeline_position", "one_sentence_definition", "core_question", "core_mechanism", "status", "source_articles", "source_evidence", "input_types", "output_types", "call_when", "do_not_call_when", "trigger_keywords", "negative_triggers", "related_models", "conflicting_models", "disciplinary_anchors", "common_misuses", "failure_modes", "selection_priority", "confidence_level", "stability_profile", "regression_status", "example_inputs", "example_outputs", "output_contract", "productization_notes", "version", "last_updated", ] MODEL_TYPE_VALUES = { "routing_model", "deep_modeling_model", "lens_model", "diagnostic_model", "evaluation_model", "generation_model", "conflict_resolution_model", "stabilization_model", } PIPELINE_POSITION_VALUES = { "pre_analysis", "analysis", "deep_analysis", "synthesis", "red_team", "evaluation", "post_processing", } CONFIDENCE_LEVEL_VALUES = {"high", "medium", "low"} REGRESSION_STATUS_VALUES = {"not_started", "pending", "in_progress", "passed", "failed", "needs_rebuild"} STABILITY_LEVEL_VALUES = {"A", "B", "C", "D"} REGRESSION_CASE_TYPE_VALUES = {"positive", "boundary", "misuse", "no_call", "selector_gate", "pipeline"} REQUIRED_REGRESSION_CASE_TYPES = {"positive", "boundary", "misuse", "no_call", "selector_gate", "pipeline"} MIN_REGRESSION_CASES_PER_MODEL = 15 EXPECTED_CLASSIFICATION_VALUES = {"question", "problem", "issue", "mixed", "no_call", "not_applicable"} EXPECTED_DOMINANT_SCARCITY_VALUES = {"data", "path_resource", "consensus_order", "mixed", "unknown", "not_applicable"} EXPECTED_MAX_DEPTH_VALUES = {"application", "domain", "process", "purpose", "core_mechanism", "human_capability", "philosophical_bedrock", "no_call", "not_applicable"} EVALUATION_MODE_VALUES = {"manual", "keyword", "structured", "semantic"} STATUS_VALUES = {"draft", "review", "reviewed", "callable", "stable", "archived", "deprecated", "draft_pre_contract"} QPI_COMPLEXITY_PATTERN_VALUES = {"not_mixed", "intra_frame_mixed", "inter_viewpoint_divergence"} MODEL_SPECIFIC_STRUCTURED_OUTPUT_FIELDS = { "qpi": [ "classification_scope", "is_provisional", "subject_position", "scenario_context", "responsibility_scope", "context_sufficiency", "missing_context", "problem_owner", "problem_source", "time_scale", "scarcity_profile", "dominant_scarcity", "classification", "classification_confidence", "evidence_gap", "misclassification_risk", "recommended_next_step", "next_model_candidates", ], "intellectual_archaeology": [ "should_call", "entry_reason", "recommended_max_depth", "layers_to_analyze", "stop_reason", "no_deeper_reason", "assumptions_by_layer", "validation_needed", "action_implication", ], } STABILITY_REQUIRED_FIELDS = [ "stability_level", "needs_stabilization", "main_risks", "reason", "next_stabilization_action", ] SOURCE_ARTICLE_REQUIRED_FIELDS = [ "source_id", "title", "source_type", "related_models", "source_status", ] SOURCE_EXCERPT_REQUIRED_FIELDS = [ "excerpt_id", "source_id", "related_model_id", "excerpt_type", "summary", "used_for", "quote_status", "source_location", ] REGRESSION_CASE_REQUIRED_FIELDS = [ "case_id", "model_id", "case_type", "input", "expected_behavior", "failure_signal", ] SOURCE_TYPE_VALUES = {"original_article", "synthesis_note", "placeholder"} SOURCE_STATUS_VALUES = {"representative", "derived_synthesis", "placeholder"} EXCERPT_TYPE_VALUES = {"definition", "taxonomy", "mechanism", "application_rule", "value_claim", "boundary_rule", "validation_rule"} QUOTE_STATUS_VALUES = {"exact", "condensed", "paraphrased"} def read_json(path): if not path.exists(): return None, [f"missing file {path.as_posix()}"] try: return json.loads(path.read_text(encoding="utf-8")), [] except json.JSONDecodeError as exc: return None, [f"{path.as_posix()} is invalid JSON: {exc}"] def require_fields(record, fields, label): return [f"{label} missing required field {field}" for field in fields if field not in record] def collect_duplicate_errors(records, key, label): seen = set() errors = [] for record in records: value = record.get(key) if value in seen: errors.append(f"duplicate {label} {value}") seen.add(value) return errors def load_collection(root, relative_path, collection_key): data, errors = read_json(root / relative_path) if errors: return [], errors if not isinstance(data, dict) or collection_key not in data or not isinstance(data[collection_key], list): return [], [f"{relative_path} must contain list field {collection_key}"] return data[collection_key], [] def validate_enum(record, field, allowed_values, label): value = record.get(field) if value is not None and value not in allowed_values: return [f"{label} field {field} has invalid value {value}"] return [] def validate_integer_range(record, field, minimum, maximum, label): value = record.get(field) if value is None: return [] errors = [] if not isinstance(value, int): return [f"{label} field {field} must be an integer"] if value < minimum: errors.append(f"{label} field {field} must be >= {minimum}") if value > maximum: errors.append(f"{label} field {field} must be <= {maximum}") return errors def validate_model_contract(model, label): errors = [] errors.extend(require_fields(model, MODEL_REQUIRED_FIELDS, label)) errors.extend(validate_enum(model, "model_type", MODEL_TYPE_VALUES, label)) errors.extend(validate_enum(model, "pipeline_position", PIPELINE_POSITION_VALUES, label)) errors.extend(validate_enum(model, "confidence_level", CONFIDENCE_LEVEL_VALUES, label)) errors.extend(validate_enum(model, "regression_status", REGRESSION_STATUS_VALUES, label)) errors.extend(validate_enum(model, "status", STATUS_VALUES, label)) errors.extend(validate_integer_range(model, "selection_priority", 1, 10, label)) stability_profile = model.get("stability_profile") if isinstance(stability_profile, dict): errors.extend(require_fields(stability_profile, STABILITY_REQUIRED_FIELDS, f"{label} stability_profile")) errors.extend(validate_enum(stability_profile, "stability_level", STABILITY_LEVEL_VALUES, f"{label} stability_profile")) needs_stabilization = stability_profile.get("needs_stabilization") if needs_stabilization is not None and not isinstance(needs_stabilization, bool): errors.append(f"{label} stability_profile field needs_stabilization must be a boolean") elif stability_profile is not None: errors.append(f"{label} field stability_profile must be an object") errors.extend(validate_structured_output_contract(model, label)) return errors def validate_structured_output_contract(model, label): model_id = model.get("model_id") required_fields = MODEL_SPECIFIC_STRUCTURED_OUTPUT_FIELDS.get(model_id, []) if not required_fields: return [] contract = model.get("structured_output_contract") if not isinstance(contract, dict): return [f"{label} model {model_id} missing structured_output_contract object"] errors = [] for field in required_fields: if field not in contract: errors.append(f"{label} model {model_id} structured_output_contract missing required output field {field}") return errors def validate_qpi_case_digests(root): path = root / "selector" / "qpi_case_digests.json" if not path.exists(): return [] data, errors = read_json(path) if errors: return errors if not isinstance(data, dict) or not isinstance(data.get("cases"), list): return ["selector/qpi_case_digests.json must contain list field cases"] errors = [] for case in data["cases"]: case_id = case.get("case_id", "") label = f"qpi case digest {case_id}" if "misframing_risks" in case: errors.append(f"{label} uses deprecated field misframing_risks; use misclassification_risk") if "mixed_or_multi_perspective" in case: errors.append(f"{label} uses deprecated field mixed_or_multi_perspective; use qpi_complexity_pattern") if "misclassification_risk" not in case: errors.append(f"{label} missing required field misclassification_risk") pattern = case.get("qpi_complexity_pattern") if pattern is None: errors.append(f"{label} missing required field qpi_complexity_pattern") elif pattern not in QPI_COMPLEXITY_PATTERN_VALUES: errors.append(f"{label} field qpi_complexity_pattern has invalid value {pattern}") if case.get("classification_scope") == "multi_perspective" or pattern == "inter_viewpoint_divergence": has_viewpoint_detail = bool(case.get("classification_by_viewpoint")) or bool(case.get("viewpoint_summary")) if not has_viewpoint_detail: errors.append(f"{label} multi_perspective case requires classification_by_viewpoint or viewpoint_summary") return errors def load_model_index(root): path = root / "models" / "model_index.json" data, errors = read_json(path) if errors: return None, errors if not isinstance(data, dict) or "models" not in data or not isinstance(data["models"], list): return None, ["models/model_index.json must contain list field models"] return data, [] def card_index_text(root): path = root / "cards" / "card_index.md" if not path.exists(): return None, ["missing file cards/card_index.md"] return path.read_text(encoding="utf-8"), [] def parse_card_index_model_ids(text): model_ids = set() for line in text.splitlines(): stripped = line.strip() if not stripped.startswith("|") or stripped.startswith("| ---") or "Model ID" in stripped: continue cells = [cell.strip() for cell in stripped.strip("|").split("|")] if cells and cells[0]: model_ids.add(cells[0]) return model_ids def validate_library(root): root = Path(root) errors = [] source_articles, article_errors = load_collection(root, "sources/source_articles.json", "source_articles") source_excerpts, excerpt_errors = load_collection(root, "sources/source_excerpts.json", "source_excerpts") regression_cases, case_errors = load_collection(root, "tests/regression_cases.json", "regression_cases") errors.extend(article_errors) errors.extend(excerpt_errors) errors.extend(case_errors) errors.extend(validate_qpi_case_digests(root)) for article in source_articles: errors.extend(require_fields(article, SOURCE_ARTICLE_REQUIRED_FIELDS, f"source article {article.get('source_id', '')}")) errors.extend(validate_enum(article, "source_type", SOURCE_TYPE_VALUES, f"source article {article.get('source_id', '')}")) errors.extend(validate_enum(article, "source_status", SOURCE_STATUS_VALUES, f"source article {article.get('source_id', '')}")) for excerpt in source_excerpts: errors.extend(require_fields(excerpt, SOURCE_EXCERPT_REQUIRED_FIELDS, f"source excerpt {excerpt.get('excerpt_id', '')}")) errors.extend(validate_enum(excerpt, "excerpt_type", EXCERPT_TYPE_VALUES, f"source excerpt {excerpt.get('excerpt_id', '')}")) errors.extend(validate_enum(excerpt, "quote_status", QUOTE_STATUS_VALUES, f"source excerpt {excerpt.get('excerpt_id', '')}")) if excerpt.get("quote_status") == "exact": raw_excerpt = excerpt.get("raw_excerpt", "") if "..." in raw_excerpt or "……" in raw_excerpt: errors.append(f"source excerpt {excerpt.get('excerpt_id')} quote_status exact must not contain ellipsis") if excerpt.get("quote_status") == "condensed" and not excerpt.get("notes"): errors.append(f"source excerpt {excerpt.get('excerpt_id')} quote_status condensed requires notes") for case in regression_cases: errors.extend(require_fields(case, REGRESSION_CASE_REQUIRED_FIELDS, f"regression case {case.get('case_id', '')}")) errors.extend(validate_enum(case, "case_type", REGRESSION_CASE_TYPE_VALUES, f"regression case {case.get('case_id', '')}")) errors.extend(validate_enum(case, "expected_classification", EXPECTED_CLASSIFICATION_VALUES, f"regression case {case.get('case_id', '')}")) errors.extend(validate_enum(case, "expected_dominant_scarcity", EXPECTED_DOMINANT_SCARCITY_VALUES, f"regression case {case.get('case_id', '')}")) errors.extend(validate_enum(case, "expected_max_depth", EXPECTED_MAX_DEPTH_VALUES, f"regression case {case.get('case_id', '')}")) errors.extend(validate_enum(case, "evaluation_mode", EVALUATION_MODE_VALUES, f"regression case {case.get('case_id', '')}")) if "should_call_model" in case and not isinstance(case.get("should_call_model"), bool): errors.append(f"regression case {case.get('case_id')} field should_call_model must be a boolean") errors.extend(collect_duplicate_errors(source_articles, "source_id", "source article id")) errors.extend(collect_duplicate_errors(source_excerpts, "excerpt_id", "source excerpt id")) errors.extend(collect_duplicate_errors(regression_cases, "case_id", "regression case id")) article_ids = {article.get("source_id") for article in source_articles} excerpt_ids = {excerpt.get("excerpt_id") for excerpt in source_excerpts} model_records = [] model_paths = sorted((root / "models").glob("*.model.json")) if (root / "models").exists() else [] if not model_paths: errors.append("models/ contains no *.model.json files") for model_path in model_paths: relative_model_path = model_path.relative_to(root).as_posix() model, model_errors = read_json(model_path) errors.extend(model_errors) if model is None: continue model_records.append((relative_model_path, model)) errors.extend(validate_model_contract(model, relative_model_path)) model_ids = {model.get("model_id") for _, model in model_records} models_by_id = {model.get("model_id"): (relative_path, model) for relative_path, model in model_records} errors.extend(collect_duplicate_errors([model for _, model in model_records], "model_id", "model id")) for relative_model_path, model in model_records: for source_id in model.get("source_articles", []): if source_id not in article_ids: errors.append(f"{relative_model_path} references unknown source article {source_id}") for excerpt_id in model.get("source_evidence", []): if excerpt_id not in excerpt_ids: errors.append(f"{relative_model_path} references unknown source excerpt {excerpt_id}") for excerpt in source_excerpts: source_id = excerpt.get("source_id") related_model_id = excerpt.get("related_model_id") if source_id and source_id not in article_ids: errors.append(f"source excerpt {excerpt.get('excerpt_id')} references unknown source article {source_id}") if related_model_id and related_model_id not in model_ids: errors.append(f"source excerpt {excerpt.get('excerpt_id')} references unknown model {related_model_id}") for case in regression_cases: model_id = case.get("model_id") if model_id and model_id not in model_ids: errors.append(f"regression case {case.get('case_id')} references unknown model {model_id}") model_index, model_index_errors = load_model_index(root) errors.extend(model_index_errors) indexed_model_ids = set() if model_index: for entry in model_index["models"]: entry_id = entry.get("model_id") indexed_model_ids.add(entry_id) model_file = entry.get("model_file") card_file = entry.get("card_file") if model_file and not (root / model_file).exists(): errors.append(f"models/model_index.json references missing model file {model_file}") if card_file and not (root / card_file).exists(): errors.append(f"models/model_index.json references missing card file {card_file}") if entry_id in models_by_id: relative_model_path, model = models_by_id[entry_id] expected_values = { "model_name": model.get("model_name"), "model_type": model.get("model_type"), "pipeline_position": model.get("pipeline_position"), "model_file": relative_model_path, "source_article_count": len(model.get("source_articles", [])), "source_evidence_count": len(model.get("source_evidence", [])), "stability_level": model.get("stability_profile", {}).get("stability_level") if isinstance(model.get("stability_profile"), dict) else None, "regression_status": model.get("regression_status"), "status": model.get("status"), } for field, expected_value in expected_values.items(): actual_value = entry.get(field) if actual_value != expected_value: errors.append(f"models/model_index.json entry {entry_id} {field} is {actual_value}, expected {expected_value}") card_index, card_index_errors = card_index_text(root) errors.extend(card_index_errors) card_index_model_ids = parse_card_index_model_ids(card_index) if card_index is not None else set() cases_by_model = {} case_types_by_model = {} for case in regression_cases: model_id = case.get("model_id") if not model_id: continue cases_by_model[model_id] = cases_by_model.get(model_id, 0) + 1 case_types_by_model.setdefault(model_id, set()).add(case.get("case_type")) if model_index: for entry in model_index["models"]: entry_id = entry.get("model_id") expected_case_count = cases_by_model.get(entry_id, 0) actual_case_count = entry.get("regression_case_count") if actual_case_count != expected_case_count: errors.append(f"models/model_index.json entry {entry_id} regression_case_count is {actual_case_count}, expected {expected_case_count}") if card_index is not None and entry_id not in card_index_model_ids: errors.append(f"models/model_index.json entry {entry_id} missing from cards/card_index.md") if model_index and card_index is not None: for card_model_id in sorted(card_index_model_ids): if card_model_id not in indexed_model_ids: errors.append(f"cards/card_index.md entry {card_model_id} missing from models/model_index.json") card_paths = sorted((root / "cards").glob("*.md")) if (root / "cards").exists() else [] for card_path in card_paths: if card_path.name in {"README.md", "card_index.md"}: continue card_model_id = card_path.stem if card_index is not None and card_model_id not in card_index_model_ids: errors.append(f"card {card_path.relative_to(root).as_posix()} missing from cards/card_index.md") for _, model in model_records: model_id = model.get("model_id") if not model_id: continue if model_index and model_id not in indexed_model_ids: errors.append(f"model {model_id} missing from models/model_index.json") if card_index is not None and model_id not in card_index_model_ids: errors.append(f"model {model_id} missing from cards/card_index.md") case_count = cases_by_model.get(model_id, 0) if case_count < MIN_REGRESSION_CASES_PER_MODEL: errors.append(f"model {model_id} has {case_count} regression cases, expected at least {MIN_REGRESSION_CASES_PER_MODEL}") present_case_types = case_types_by_model.get(model_id, set()) for required_case_type in sorted(REQUIRED_REGRESSION_CASE_TYPES): if required_case_type not in present_case_types: errors.append(f"model {model_id} missing regression case type {required_case_type}") return errors def write_report(root, errors): report_path = Path(root) / "reports" / "validation_report.md" report_path.parent.mkdir(parents=True, exist_ok=True) status = "PASS" if not errors else "FAIL" lines = [ "# Validation Report", "", f"Status: `{status}`", "", "Command: `python scripts/validate_model_library.py`", "", ] if errors: lines.append("## Errors") lines.append("") lines.extend(f"- {error}" for error in errors) else: lines.append("## Result") lines.append("") lines.append("- JSON 文件可解析。") lines.append("- 模型必填字段存在并符合本地 contract 子集。") lines.append("- source article、source excerpt、regression case 引用完整。") lines.append("- model/card index 引用完整,计数、状态和索引投影一致。") report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") return report_path def main(): root = Path(__file__).resolve().parents[1] errors = validate_library(root) report_path = write_report(root, errors) print(f"validation report written to {report_path}") if errors: for error in errors: print(f"ERROR: {error}") return 1 print("validation passed") return 0 if __name__ == "__main__": sys.exit(main())