from __future__ import annotations import argparse import json from pathlib import Path from typing import Any def read_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def diff_named_range(change: dict[str, Any] | None) -> dict[str, Any]: change = change or {} one_based = change.get("one_based") if isinstance(change.get("one_based"), dict) else {} raw_scalars = change.get("raw_scalars") if isinstance(change.get("raw_scalars"), dict) else {} before_coords = one_based.get("before") if isinstance(one_based.get("before"), dict) else {} after_coords = one_based.get("after") if isinstance(one_based.get("after"), dict) else {} before_raw = raw_scalars.get("before") if isinstance(raw_scalars.get("before"), list) else [] after_raw = raw_scalars.get("after") if isinstance(raw_scalars.get("after"), list) else [] changed_fields: list[int] = [] for index, (before, after) in enumerate(zip(before_raw, after_raw)): if before != after: changed_fields.append(index) return { "before_coords": before_coords, "after_coords": after_coords, "before_raw": before_raw, "after_raw": after_raw, "changed_scalar_indexes": changed_fields, } def classify_transition(transition: dict[str, Any]) -> dict[str, Any]: named = diff_named_range(transition.get("named_range_changes") if isinstance(transition.get("named_range_changes"), dict) else {}) before_coords = named["before_coords"] after_coords = named["after_coords"] text_changes = transition.get("text_match_changes") if isinstance(transition.get("text_match_changes"), dict) else {} before_next = (text_changes.get("next_moxel_record") or {}).get("before") after_next = (text_changes.get("next_moxel_record") or {}).get("after") count_changes = transition.get("count_changes") if isinstance(transition.get("count_changes"), dict) else {} top_before = before_coords.get("row_start") top_after = after_coords.get("row_start") left_before = before_coords.get("column_start") left_after = after_coords.get("column_start") labels: list[str] = [] explanation: list[str] = [] if top_before is not None and top_after is not None and left_before is not None and left_after is not None: if top_before != top_after and left_before == left_after: labels.append("row_move") explanation.append("Named range moved vertically while column stayed stable.") if left_before != left_after and top_before == top_after: labels.append("column_move") explanation.append("Named range moved horizontally while row stayed stable.") if left_before == left_after and top_before == top_after and before_coords: labels.append("same_named_range_coordinates") explanation.append("Named range coordinates stayed stable.") if isinstance(before_next, dict) and isinstance(after_next, dict): if before_next.get("type") != after_next.get("type"): labels.append("next_record_type_change") explanation.append("Text-cell next_moxel_record changed node type.") if before_next.get("head") != after_next.get("head"): labels.append("next_record_head_change") explanation.append("Text-cell next_moxel_record changed list head.") if before_next.get("scalar_prefix") != after_next.get("scalar_prefix") and before_next.get("head") == after_next.get("head"): labels.append("next_record_payload_change") explanation.append("Text-cell next_moxel_record kept the same head but changed payload.") elif before_next != after_next: labels.append("next_record_presence_change") explanation.append("Text-cell next_moxel_record appeared/disappeared or changed from scalar to structured form.") if "cell_style_candidates" in count_changes: labels.append("candidate_count_change") explanation.append("The number of visible inline text/style candidates changed.") target_signal_change_keys = { "cell_id", "tree_position", "next_moxel_record", "immediate_preceding_values", "last_7_preceding_values", } target_signal_changed = any(key in text_changes for key in target_signal_change_keys) named_coordinates_changed = bool(before_coords or after_coords) bytes_changed = transition.get("from_bytes") != transition.get("to_bytes") if bytes_changed and not named_coordinates_changed and not target_signal_changed and not count_changes: labels.append("tracked_signature_unchanged") explanation.append("Template bytes changed, but tracked coordinates and text-cell signature stayed stable.") if not labels: labels.append("uncategorized") explanation.append("No simple heuristic label matched this transition.") return { "from_file": transition.get("from_file"), "to_file": transition.get("to_file"), "labels": labels, "explanation": explanation, "named_range_diff": named, "next_moxel_record_before": before_next, "next_moxel_record_after": after_next, "count_changes": count_changes, } def summarize_patterns(classified: list[dict[str, Any]]) -> dict[str, Any]: label_counts: dict[str, int] = {} for item in classified: for label in item.get("labels") or []: label_counts[label] = label_counts.get(label, 0) + 1 return { "label_counts": [{"label": label, "count": count} for label, count in sorted(label_counts.items(), key=lambda pair: (-pair[1], pair[0]))] } def recommend_next_steps(classified: list[dict[str, Any]]) -> list[str]: labels = {label for item in classified for label in (item.get("labels") or [])} steps: list[str] = [] if "row_move" in labels and "column_move" in labels: steps.append("Coordinates for named cell/range are already proven; prioritize property-only changes on the same cell.") if "next_record_head_change" in labels or "next_record_type_change" in labels: steps.append("Change one presentation/property setting on `Ячейка 7 - 2` without moving it: `ВертикальноеПоложение`, then `ГоризонтальноеПоложение`, then `Защита`, then `Гиперссылка`.") if "candidate_count_change" in labels: steps.append("Avoid adding/removing neighbor cells during property experiments; that changes the candidate count and adds noise.") if "tracked_signature_unchanged" in labels: steps.append("When a save changes template bytes but leaves the tracked signature untouched, the edited property is either stored elsewhere or belongs to another cell record; capture one more isolated property change on the same cell before widening the search.") if not steps: steps.append("Continue with one-property-per-save experiments on the same tracked cell.") return steps def main() -> int: parser = argparse.ArgumentParser(description="Infer heuristic property candidates from a 1C template history matrix.") parser.add_argument("matrix_json", help="Path to JSON generated by analyze_1c_template_history_matrix.py") parser.add_argument("--output", help="Optional JSON output path.") args = parser.parse_args() matrix = read_json(Path(args.matrix_json)) transitions = matrix.get("transitions") or [] classified = [classify_transition(item) for item in transitions if isinstance(item, dict)] payload = { "schema": "codex_1c_template_property_candidates.v1", "source": args.matrix_json, "classified_transitions": classified, "pattern_summary": summarize_patterns(classified), "recommended_next_steps": recommend_next_steps(classified), } rendered = json.dumps(payload, ensure_ascii=False, indent=2) if args.output: Path(args.output).write_text(rendered, encoding="utf-8") print(rendered) return 0 if __name__ == "__main__": raise SystemExit(main())