518 lines
22 KiB
Python
518 lines
22 KiB
Python
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", "<missing>")
|
|
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', '<missing>')}"))
|
|
errors.extend(validate_enum(article, "source_type", SOURCE_TYPE_VALUES, f"source article {article.get('source_id', '<missing>')}"))
|
|
errors.extend(validate_enum(article, "source_status", SOURCE_STATUS_VALUES, f"source article {article.get('source_id', '<missing>')}"))
|
|
for excerpt in source_excerpts:
|
|
errors.extend(require_fields(excerpt, SOURCE_EXCERPT_REQUIRED_FIELDS, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
|
|
errors.extend(validate_enum(excerpt, "excerpt_type", EXCERPT_TYPE_VALUES, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
|
|
errors.extend(validate_enum(excerpt, "quote_status", QUOTE_STATUS_VALUES, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
|
|
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', '<missing>')}"))
|
|
errors.extend(validate_enum(case, "case_type", REGRESSION_CASE_TYPE_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
|
|
errors.extend(validate_enum(case, "expected_classification", EXPECTED_CLASSIFICATION_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
|
|
errors.extend(validate_enum(case, "expected_dominant_scarcity", EXPECTED_DOMINANT_SCARCITY_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
|
|
errors.extend(validate_enum(case, "expected_max_depth", EXPECTED_MAX_DEPTH_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
|
|
errors.extend(validate_enum(case, "evaluation_mode", EVALUATION_MODE_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
|
|
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())
|