-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_full_proof_graph.py
More file actions
3475 lines (3131 loc) · 148 KB
/
Copy pathgenerate_full_proof_graph.py
File metadata and controls
3475 lines (3131 loc) · 148 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Generative Logic: A deterministic reasoning and knowledge generation engine.
# Copyright (C) 2025-2026 Generative Logic UG (haftungsbeschränkt)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# ------------------------------------------------------------------------------
#
# This software is also available under a commercial license. For details,
# see: https://generative-logic.com/license
#
# Contributions to this project must be made under the terms of the
# Contributor License Agreement (CLA). See the project's CONTRIBUTING.md file.
# !/usr/bin/env python3
"""
generate_full_proof_graph.py
Generates a set of HTML proof pages:
- An index page with a Table of Contents
- One HTML file per theorem (with navigation links)
Each theorem tuple is now (theorem_name:str, method:str, var_name:str).
Default output directory: full_proof_graph
"""
import copy
import json
import os
import html
import re
import shutil
import expression_utils
from expression_utils import disintegrate_implication, replace_keys_in_string
from typing import Dict
from configuration_reader import configuration_reader
from parameters import debug
# =============================================================================
# License metadata injected into every generated HTML page.
# Kept as module-level constants so the three page templates (tags, index,
# chapter) stay in sync. GL is dual-licensed: AGPLv3 + commercial.
# =============================================================================
LICENSE_SOURCE_COMMENT = """<!--
Generative Logic proof output
Copyright © 2025-2026 Generative Logic UG
Licensed under GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
Commercial use for AI model training, dataset construction, or
commercial redistribution requires a commercial license:
https://generative-logic.com/license/
-->"""
LICENSE_HEAD_META = """ <!-- License metadata -->
<meta name="copyright" content="© 2025-2026 Generative Logic UG">
<meta name="license" content="AGPL-3.0-or-later">
<meta name="robots" content="noindex, follow, noai, noimageai">
<link rel="license" href="https://www.gnu.org/licenses/agpl-3.0.html">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CreativeWork",
"name": "Generative Logic Proof Graph",
"copyrightHolder": {
"@type": "Organization",
"name": "Generative Logic UG",
"url": "https://generative-logic.com/"
},
"copyrightYear": 2026,
"license": "https://www.gnu.org/licenses/agpl-3.0.html",
"isAccessibleForFree": true,
"usageInfo": "https://generative-logic.com/license/"
}
</script>"""
LICENSE_FOOTER = """ <div style="margin-top:2em; padding-top:1em; border-top:1px solid #3A3D4A; font-size:0.8em; color:#8B8FA5; display:flex; align-items:center; gap:0.9em;">
<a href="https://generative-logic.com" style="display:inline-flex; align-items:center; flex-shrink:0;" aria-label="Generative Logic homepage">
<img src="gl-logo.png" alt="Generative Logic" width="40" height="40" style="display:block;" />
</a>
<span>
Proof graph structure, presentation, and provenance chains
© 2025-2026 Generative Logic UG. Licensed under
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a>.
Commercial use for AI model training, dataset construction, or
commercial redistribution requires a
<a href="https://generative-logic.com/license/">commercial license</a>.
</span>
</div>"""
import visu_helpers
from visu_helpers import expand_expr
from pathlib import Path
# wherever this file lives, assume the project root is its parent folder
PROJECT_ROOT = Path(__file__).resolve().parent
# global mapping from displayed theorem title → its chapter file
theorem_to_file = {}
# alpha-normalized theorem shape -> file (only unique matches)
theorem_shape_to_file = {}
# GL-binary map populated by generate_proof_graph_pages. Used by
# _subproof_explanation to inline the expanded MPL form of an or<N>
# expression beside the symbolic name (so the reader sees the full
# !(&!E1!E2) De-Morgan form right next to the abbreviated `(orK[..])`).
_gl_binary_map_for_render: dict = {}
# Sibling-batch (cross-pipeline) lookup. Populated when
# generate_proof_graph_pages receives a `sibling_graphs` parameter so an
# incubator chapter that cites a Peano-batch external theorem can link
# directly into the main pipeline's HTML output. Targets carry relative
# URLs from the *current* output dir to the sibling's HTML output dir.
# Rendered links open in a new browser tab (target="_blank") so the
# sibling proof opens beside, not in place of, the current chapter.
sibling_theorem_to_file = {}
sibling_theorem_shape_to_file = {}
# Set of alpha-equivalent shapes for every external theorem that this
# pipeline registers (loaded from this run's processed proof graph
# external_theorems.txt). Used to detect cited rules that are "known
# external" but resolve to no chapter — neither locally nor in any
# sibling. Such cells render as orphan externals: no link, but the
# right-click popup explains the situation instead of just dumping the
# expression. The orphan path covers user-supplied externally-provided
# theorems whose proof graph is genuinely not part of any pipeline
# release.
external_orphan_shapes: set[str] = set()
# Tag descriptions for the proof graph reference page and right-click popups
TAG_DESCRIPTIONS = {
"implication": (
"Hash-table inference",
"A premise was matched against a universally quantified implication rule "
"stored in hash memory, and the conclusion was emitted. "
"The first dependency is the rule itself; the remaining dependencies are "
"the expressions that matched the rule's premises."
),
"expansion": (
"Named expression expanded",
"A named compound expression (e.g. NaturalNumbers, fXY) was expanded "
"into its compiled definition structure from the GL binary. "
"The dependency is the named expression that was expanded."
),
"compilation": (
"Implication compiled to compact form",
"An implication entering the mail broadcast channel was also "
"compiled to its compact named form "
"<code>(implication<N>[args])</code>. The row's expression is "
"the compact form; the single dependency is the original expanded "
"implication it was compiled from. The compact name's GL-binary "
"definition, instantiated with its arguments, reconstructs the "
"original. Preparatory provenance for the ASIC build; it does not "
"change which theorems are proved."
),
"disintegration": (
"Compound expression decomposed",
"A conjunction (&), existence, or OR node was broken apart into its "
"constituent sub-expressions. For conjunctions, each element is extracted. "
"For existence nodes, the left element (with a fresh bound variable) and "
"the right element are produced. "
"For OR nodes, K mutual-exclusion sub-implications of the form "
"<code>(!D_others → D_k)</code> are emitted — one per disjunct "
"— so a two-disjunct OR <code>A ∨ B</code> disintegrates into the "
"rule pair <code>!A → B</code> and <code>!B → A</code>; the "
"per-branch case-split that follows (one scope per disjunct) is tagged "
"separately as <i>or disintegration</i>."
),
"task formulation": (
"Proof premise (root assumption)",
"A root premise of the theorem under proof, asserted without justification "
"at the top of the chapter. In direct proofs, the row's left side is one of "
"the theorem implication's chain premises (or the anchor application, which "
"is always a premise by structure). In contradiction proofs — theorems "
"whose head starts with <code>!</code> — the un-negated head is also "
"valid as a task-formulation row, seeded into the contradiction LB as the "
"hypothesis to be disproved. Symmetrically, for a positive-headed theorem "
"proved by reductio, the negated head is valid as a task-formulation row "
"— the complement contradiction LB's assumed hypothesis. "
"Induction-hypothesis seeding uses the "
"<i>recursion</i> tag, not task formulation."
),
"equality1": (
"Argument substitution via equality",
"An expression's argument was replaced using an equality fact. "
"If (=[a,b]) is known and f(...,a,...) exists, then f(...,b,...) is derived."
),
"equality2": (
"Transitivity of equality",
"From (=[a,b]) and (=[b,c]), the equality (=[a,c]) is derived. "
"This is the standard transitivity rule for the equality relation."
),
"symmetry of equality": (
"Symmetry of equality",
"From (=[a,b]), the symmetric equality (=[b,a]) is derived. "
"This is the standard symmetry rule for the equality relation."
),
"symmetry of inequality": (
"Symmetry of negated equality",
"From !(=[a,b]), the symmetric inequality !(=[b,a]) is derived. "
"The mirror of <i>symmetry of equality</i> for negated equality: "
"equality is symmetric, and so is its negation."
),
"recursion": (
"Induction-hypothesis seeding",
"Induction-hypothesis row at the top of an induction triad's check chapter. "
"In <code>check_zero</code> chapters, the induction variable is identified "
"with <code>i0</code> (the base case). In "
"<code>check_induction_condition</code> chapters, the successor form is "
"asserted as the inductive step's premise — <code>s(v_prev) = v_current</code> "
"— identifying the current step variable as the successor of the "
"previous-step variable to which the hypothesis applies."
),
"theorem": (
"Previously proved theorem",
"A theorem that was proved in an earlier chapter is used as an inference "
"rule. The dependency links to the chapter where the theorem was originally proved."
),
"reformulation for integration and": (
"Reformulated for reverse-disintegration (and)",
"An expression of category <i>and</i> was reformulated into a chain of "
"nested implications <code>(>[]elem1(>[]elem2...compact))</code> "
"suitable for integration. The expanded <code>(&...)</code> form is "
"flattened element-by-element, then re-wrapped right-to-left around the "
"compact head so the expression can be reassembled by the integration "
"step."
),
"reformulation for integration >[bound]": (
"Reformulated for reverse-disintegration (existence, bound retained)",
"An expression of category <i>existence</i> was reformulated into the "
"negated-existence implication form for integration. The outermost "
"<code>>[...]</code> retains a bound variable (typically "
"<code>pi_lev_<N></code>) introduced by the reformulation; the "
"verifier compares the converted form against the canonical existence "
"shape derived from the GL binary entry."
),
"reformulation for integration >[]": (
"Reformulated for reverse-disintegration (existence, empty bound)",
"An expression of category <i>existence</i> was reformulated into the "
"negated-existence implication form for integration, with an empty "
"outermost <code>>[]</code>. The bound variable was stripped because "
"the slot was already occupied; the verifier infers it from the GL "
"binary entry and validates the existence-form match."
),
"expansion for integration": (
"Expanded for reverse-disintegration",
"A named expression was expanded specifically in preparation for integration. "
"The expanded form provides the structural template needed for the "
"reverse-disintegration step."
),
"premise element": (
"Implication premise consumed",
"A premise of an implication was matched during the integration process. "
"This marks one of the conditions that needed to be satisfied before "
"the implication's conclusion could be assembled."
),
"validity name": (
"Implication scope identifier",
"Ties an expression to the specific implication whose scope it belongs to "
"during the integration process. Ensures that premises and conclusions "
"are matched within the correct logical context."
),
"anchor handling": (
"Anchor slot rename",
"Pin a raw bound-variable index in an anchor application to its "
"anchor-slot name. The dependency is the original anchor expression; the "
"row's left side is the renamed form (e.g. "
"<code>(AnchorPeano[N,0_copy,s,+,*,i1])</code> ← "
"<code>(AnchorPeano[N,i0,s,+,*,i1])</code> — the slot at position 2 "
"becomes <code>0_copy</code>). At most one emission per chapter; subsequent "
"occurrences of the same slot are tracked through <i>_copy</i> substitutions, "
"not through additional anchor-handling rows."
),
"reformulated from": (
"Reformulation of source theorem",
"This theorem is a reformulation of another theorem — the head (conclusion) "
"has been rewritten using an existence-node expansion from the GL binary. "
"The dependency links to the original source theorem."
),
"variable copy": (
"Variable copy axiom",
"GL may introduce a free equality (=[Y, Y_copy]) at any point in any "
"scope, where Y_copy is a fresh name manufactured by appending the "
"suffix "_copy" to Y. Because Y_copy never occurs anywhere "
"else in the model, this is a conservative extension — asserting that "
"Y_copy equals Y cannot contradict anything. GL uses this mechanism "
"to split duplicated argument positions (when a rule instantiation "
"would force the same variable into two slots of the same expression) "
"so that the disintegration and hash-burst machinery can treat the "
"two positions independently. The equality is then reused via "
"equality1 rewrites to glue them back together. The verifier enforces "
"that every occurrence of a _copy variable elsewhere in the chapter "
"has a provenance chain ending at a variable copy declaration for "
"that exact equality."
),
"externally provided theorem": (
"External theorem (not proved by GL)",
"A theorem injected from the externally_provided_theorems.txt file. "
"This theorem was not proved by GL's own deduction engine but is "
"accepted as a given fact for use in downstream proofs."
),
"incubator back reformulation": (
"Back-reformulated operator theorem",
"An operator-equality theorem (e.g., a+b=c) was back-reformulated from "
"the implication form into a direct operator statement. The dependency "
"links to the source proof."
),
"contradiction": (
"Proved by contradiction",
"Both an expression and its negation were derived within the same "
"logic block, establishing a contradiction. The theorem is proved "
"because the negation of the conclusion led to an inconsistency."
),
"vacuous truth": (
"Premise chain self-contradictory",
"The premise chain leading to an implication was shown to be "
"self-contradictory inside the implication's scope. The implication "
"head is therefore trivially valid — <i>ex falso quodlibet</i>. "
"Allows GL to close out branches whose premises lead to inconsistency "
"without needing to derive the head separately. Currently confined to "
"scope <code>main</code>."
),
"origin": (
"Provenance tracking (non-checker)",
"Non-checker chapter-meta tag. Tracks the dependency chain for an "
"expression so the verifier can walk back to root assumptions or task "
"formulations. Used most prominently in the contradiction-trace and "
"vacuous-truth-trace meta-checks — the row's rest fields name "
"the source derivation(s) that each chapter row's expression depends "
"on, letting the verifier confirm a contradiction's grounding chain "
"or a vacuous-truth's recursion-hypothesis route. Lives outside the "
"<code>TAG_CHECKERS</code> dispatch table; counted as its own success/"
"failure line in the verifier output."
),
"multiplied from": (
"Partition-based variable equalization",
"A theorem produced by the multiplyImplication algorithm. Bell partitions "
"of bound variables generate copies where variable groups are set equal, "
"enabling cross-expression equalization."
),
"or disintegration": (
"Case analysis branch",
"A disjunction (OR expression) was decomposed into individual cases. "
"Each disjunct is processed under its own branch validity scope. "
"The dependency is the expanded OR expression encoding all disjuncts."
),
"or convergence": (
"Case analysis convergence",
"All branches of a case analysis (OR disintegration) have independently "
"proved the same expression. The results converge back to the parent "
"validity scope, completing the case analysis."
),
"or theorem": (
"OR-theorem chapter conclusion",
"Marks a chapter whose theorem statement <em>is</em> a disjunction "
"(an <code>or<N>[...]</code> node). Distinct from the per-branch "
"bookkeeping rows <i>or branch proven</i> and <i>or branch assumption</i>: "
"this is the chapter-conclusion record for theorems whose head is an OR. "
"Emitted as a <code><N>_or_theorem.txt</code> proof file with method "
"label <code>or theorem</code>."
),
"or branch proven": (
"OR-introduction subproof seed",
"Records that the parent-scope OR <code>(or<N>[...])</code> was "
"opened into a per-subproof scope carrying one specific disjunct as "
"its asserted target. Each disjunct gets its own <i>or branch proven</i> "
"row pinning the parent OR to that subproof's namespace "
"(<code>parent_boundary_orint_<or>_(<disjunct>)</code>). "
"When any one subproof closes, the OR is emitted at the parent scope "
"— this row IS the OR's derivation by design (no separate "
"derivation row). The historical name "branch" refers to the "
"OR-introduction sub-proof, not a case-split branch (those are recorded "
"as <i>or disintegration</i>). See D-36 for the terminology note."
),
"or branch assumption": (
"OR-introduction subproof side-assumption",
"Inside an OR-introduction subproof asserting disjunct <code>D_i</code> "
"as the target, the negation <code>!D_j</code> of every other disjunct "
"(<code>j ≠ i</code>) is seeded as a subproof-local assumption. "
"This makes the OR-introduction sound — under "
"<code>(!D_j → D_i)</code>, deriving <code>D_i</code> from "
"<code>!D_j</code> witnesses one of the K mutual-exclusion sub-implications "
"of the OR. Row layout: the negated-other-disjunct paired with the "
"subproof namespace, with the parent-scope OR expression cited as "
"<code><or-expr>_integration_goal</code>."
),
}
def _find_matching_paren_local(expr: str, start: int) -> int:
"""Return the index of the ')' matching '(' at position *start*, or -1."""
depth = 0
for i in range(start, len(expr)):
if expr[i] == '(':
depth += 1
elif expr[i] == ')':
depth -= 1
if depth == 0:
return i
return -1
def _alpha_normalize_theorem_expr(expr: str) -> str:
"""Normalize theorem expressions up to variable renaming (alpha-equivalence).
We replace every bracket-argument token with a stable placeholder by first occurrence.
This lets instantiated theorem schemas (e.g. broadcasted versions) match the generic theorem page.
"""
if not expr or not isinstance(expr, str):
return expr
s = expr.strip()
if not (s.startswith('(>') or s.startswith('!(')):
return s
token_pattern = re.compile(r'(?<=[\[,])([^,\[\]]+)(?=[\],])')
mapping = {}
next_idx = 0
def repl(m):
nonlocal next_idx
tok = m.group(1)
base = _strip_u_prefixes(tok)
if base not in mapping:
mapping[base] = f"@{next_idx}"
next_idx += 1
return mapping[base]
return token_pattern.sub(repl, s)
def _resolve_theorem_target(expr: str):
target = theorem_to_file.get(expr)
if target:
return target
shape = _alpha_normalize_theorem_expr(expr)
return theorem_shape_to_file.get(shape)
def _resolve_sibling_theorem_target(expr: str):
"""Cross-batch lookup: if `expr` matches a theorem proven in a sibling
pipeline (e.g. an incubator chapter citing a Peano-batch external),
return the relative URL to the sibling's chapter HTML. Returns None
when the expression is not a known sibling theorem.
"""
target = sibling_theorem_to_file.get(expr)
if target:
return target
shape = _alpha_normalize_theorem_expr(expr)
return sibling_theorem_shape_to_file.get(shape)
def _is_orphan_external(expr: str) -> bool:
"""True iff `expr` is a known external (registered in this pipeline's
external_theorems.txt) but resolves to no chapter — neither locally
nor in any sibling pipeline. Such cells render as orphan externals
(no link, but a right-click popup explains).
"""
if not expr:
return False
shape = _alpha_normalize_theorem_expr(expr)
if not shape:
return False
return shape in external_orphan_shapes
def _strip_i_prefix(expr: str) -> str:
"""Strip 'i' prefix from anchor element names (i0->0, i1->1) for display.
Works on both bracketed args and readable math text."""
return re.sub(r'\bi(\d+)\b', r'\1', expr)
# Extract top-level constituent expression names for GL binary display
_EXPR_NAME_RE = re.compile(r'\(([A-Za-z_][A-Za-z0-9_]*)\[')
def _extract_expr_parts(expr: str) -> str:
"""Extract unique expression names (e.g. 'in2', 'in3', 'AnchorPeano') from an expression.
Returns comma-separated string of names that exist in the GL binary map."""
names = list(dict.fromkeys(_EXPR_NAME_RE.findall(expr))) # unique, order-preserving
return ','.join(names) if names else ''
def _space_commas(s: str) -> str:
"""Add spaces after commas inside [...] brackets for display readability."""
return re.sub(r',(?=[^\s])', ', ', s)
# Utility function to wrap clickable substrings starting with '(' or '!(' and ending at space or end-of-string
def wrap_clickable(text):
def repl(m):
s = m.group(0) # the raw token, e.g. "(v7*v8)=(v8*v7)"
esc = html.escape(s, quote=True)
parts = html.escape(_extract_expr_parts(s), quote=True)
parts_attr = f' data-parts="{parts}"' if parts else ''
target = _resolve_theorem_target(s)
if target:
# Display with w-variables, keep original v-expression for link target and data-text
display = html.escape(_strip_i_prefix(s), quote=True)
span = f'<span class="clickable" data-text="{esc}"{parts_attr}>{display}</span>'
return f'<a href="{target}" class="theorem-link">{span}</a>'
# Cross-batch (sibling) lookup: if the expression matches a theorem
# proven in a sibling pipeline (e.g. an incubator chapter citing a
# Peano-batch external), link to the sibling's HTML chapter. Open
# the link in a new tab (target="_blank") so the sibling proof
# opens beside the current chapter rather than replacing it.
sibling_target = _resolve_sibling_theorem_target(s)
if sibling_target:
display = html.escape(_strip_i_prefix(s), quote=True)
span = f'<span class="clickable" data-text="{esc}"{parts_attr}>{display}</span>'
return (f'<a href="{sibling_target}" class="theorem-link external-link" '
f'target="_blank" rel="noopener">{span}</a>')
# Orphan-external case: the expression is a registered external in
# this pipeline's external_theorems.txt but resolves to no chapter
# in any sibling. Mark it so the right-click popup explains the
# situation instead of just dumping the expression. Rendered with
# the same lavender colour as cross-batch links plus a dashed
# underline (no ↗) to distinguish from clickable externals.
if _is_orphan_external(s):
display_esc = html.escape(_strip_i_prefix(s), quote=True)
span = (f'<span class="clickable external-orphan" data-text="{esc}" '
f'data-external-orphan="1"{parts_attr}>{display_esc}</span>')
return span
# span for the normal "expand on right-click".
# The per-implication local w/W rename for non-anchor bound vars
# now lives in process_proof_graphs.py (ITERATION 4½), so the
# processed proof graph already carries the rendered form. Just
# render the cell as-is — both verifier and HTML see the same form.
display_esc = html.escape(_strip_i_prefix(s), quote=True)
span = f'<span class="clickable" data-text="{esc}"{parts_attr}>{display_esc}</span>'
return span
pattern = r"!?\([^ ]*?\)(?= |$)"
result = re.sub(pattern, repl, text)
# Strip i-prefix from plain text segments (outside HTML tags)
result = re.sub(r'(?<![<"\w])i(\d+)(?!["\w>])', r'\1', result)
# Wrap _integration_goal suffix as a right-clickable label
result = result.replace(
'_integration_goal',
'<span class="integration-goal-label">_integration_goal</span>')
return result
def _htmlify_readable(text):
"""Convert plain-text readable title to HTML with mathematical notation."""
h = html.escape(text)
# (preorder[N,+,a,b]) -> a ≤ b (∃k∈N. a+k=b)
h = re.sub(
r'\(preorder\[([^,]+),([^,]+),([^,]+),([^\]]+)\]\)',
lambda m: f'{m.group(3)} ≤ {m.group(4)}',
h)
# (interval[N,+,start,end,set]) -> set = [start,end]
# !(interval[N,+,start,end,set]) -> set ≠ [start,end]
h = re.sub(
r'(!?)\(interval\[([^,]+),([^,]+),([^,]+),([^,]+),([^\]]+)\]\)',
lambda m: f'{m.group(6)} {"≠" if m.group(1) else "="} [{m.group(4)},{m.group(5)}]',
h)
# (EnumerationSetN[a,...,M]) -> M = {a,...} for every arity N
# !(EnumerationSetN[a,...,M]) -> M ≠ {a,...}
def _fmt_enum_set(m):
args = m.group(2).split(',')
rel = '≠' if m.group(1) else '='
return f'{args[-1]} {rel} {{{",".join(args[:-1])}}}'
h = re.sub(r'(!?)\(EnumerationSet\d+\[([^\]]+)\]\)', _fmt_enum_set, h)
# (fXY[a,B,C]) -> a: B -> C
h = re.sub(
r'\(fXY\[([^,]+),([^,]+),([^\]]+)\]\)',
lambda m: f'{m.group(1)}: {m.group(2)} \u2192 {m.group(3)}',
h)
# (sequence[N,+,c,a,b]) -> b is a sequence (b^i)_{i in [c,a]}
h = re.sub(
r'\(sequence\[([^,]+),([^,]+),([^,]+),([^,]+),([^\]]+)\]\)',
lambda m: f'{m.group(5)} is a sequence ({m.group(5)}<sup>i</sup>)<sub>i\u2208[{m.group(3)},{m.group(4)}]</sub>',
h)
# sum(i=start..end) -> vertical sigma with bounds above/below, summing i
def _fmt_sum(m):
lo, hi, fn = m.group(1), m.group(2), m.group(3)
body = f'{fn}(i)' if fn else 'i'
sigma = (
'<span class="sm" style="display:inline-flex;flex-direction:column;'
'align-items:center;vertical-align:middle;margin:0 2px;line-height:1">'
f'<span style="font-size:0.6em">{hi}</span>'
'<span style="font-size:1.8em;line-height:0.8;margin-bottom:8px">\u2211</span>'
f'<span style="font-size:0.6em;margin-top:8px">i={lo}</span>'
'</span>'
)
return f'{sigma} {body}'
h = re.sub(r'sum\(i=([^.]+)\.\.([^)]+)\)(?: (\w+)\(i\))?', _fmt_sum, h)
# enlarge parentheses that directly wrap a sigma block
h = re.sub(
r'\(([^()]*?<span class="sm".*?</span>[^()]*?)\)',
lambda m: (
'<span style="font-size:2em;vertical-align:middle;font-weight:normal">(</span>'
+ m.group(1) +
'<span style="font-size:2em;vertical-align:middle;font-weight:normal">)</span>'
), h)
# Space around '=' in text segments only (skip HTML tags/attributes)
parts = re.split(r'(<[^>]+>)', h)
h = ''.join(
re.sub(r'(?<!=)\s*=\s*(?!=)', ' = ', p) if not p.startswith('<') else p
for p in parts
)
# v/V/w/W N → letter<sub>N</sub> in text segments only (case-preserving).
parts = re.split(r'(<[^>]+>)', h)
h = ''.join(
re.sub(r'([vVwW])(\d+)', r'\1<sub>\2</sub>', p) if not p.startswith('<') else p
for p in parts
)
# Standalone `N` (the natural-numbers anchor slot) -> blackboard-bold
# ℕ (the LaTeX \mathbb{N} convention). Word-boundary match avoids
# touching `N` embedded in operator names like `NaturalNumbers`,
# `inN`, or attribute values inside HTML tags. Wrapped in a `bb-N`
# span styled by CSS to render heavier and slightly larger than
# surrounding text — Arial's bare ℕ glyph reads thin and small
# against the bold readable-text weight.
parts = re.split(r'(<[^>]+>)', h)
h = ''.join(
re.sub(r'\bN\b', '<span class="bb-N">ℕ</span>', p) if not p.startswith('<') else p
for p in parts
)
# Add breathing room around scaffolding keywords
for kw in ['RULE:', 'IMPLIES:', 'from', 'follows', 'and',
'reformulated from', 'back-reformulated from', 'is a sequence']:
h = h.replace(kw, f' {kw} ')
return h
def _format_validity_tag(validity: str, namespace_anchor_map: dict | None = None) -> str:
if not validity:
return ""
raw = validity.strip()
# Strip the `i`-prefix from anchor element names for display
# consistency with how regular expressions are rendered (e.g.
# `i0`/`i1` -> `0`/`1`). Pre-fix the namespace strings showed
# raw `i0`, `i1`, ... while the regular expressions rendered as
# `0`, `1`, .... Same `_strip_i_prefix` regex used for both.
display = _strip_i_prefix(raw)
esc = html.escape(display)
if display.startswith("(") and display.endswith(")"):
body = f'<span class="validity-tag">{esc}</span>'
else:
body = f'<span class="validity-tag">({esc})</span>'
# Every namespace renders unclickable, matching `(main)`. The
# `namespace_anchor_map` parameter is accepted for callsite
# compatibility but intentionally ignored — the prior subproof-
# cross-link path turned non-main namespaces into `<a ns-jump>`
# links, which the user wants gone.
return f' {body}'
def format_stack_entries(stack, prefix='', cursor_index=None, reverse_entries=True, external_anchor_map=None, goal_key_norm=None, global_counter=None, global_total=None, namespace_anchor_map=None, chapter_ns_map=None):
"""
Convert a proof-stack (list of [key, validity, explanation, ing1, val1, ...]) into HTML.
New Format Structure:
0: Result Expression (Key)
1: Result Validity
2: Explanation (Method/Justification)
3, 5, 7...: Ingredient Expressions
4, 6, 8...: Ingredient Validities
Step-numbering:
- When `global_counter` (a one-element mutable list `[next_idx]`) and
`global_total` (int) are provided, every visible row's step badge
reads `(global_counter[0]/global_total)` and the counter advances
across nested calls. This is how the chapter-wide common
numbering threads through main stack + every subproof + nested
subproofs in one continuous sequence.
- When omitted (caller did not opt in), the function falls back to
local-only `(local_idx/local_total)` numbering — the legacy
behaviour preserved for any direct caller that wants per-section
counts.
"""
# Reverse so the earliest step is first in the output (legacy behavior)
rev = list(stack)[::-1] if reverse_entries else list(stack)
# Map each key (normalized) to a unique anchor ID
key_map = {}
external_anchor_map = external_anchor_map or {}
for idx, entry in enumerate(rev):
if not entry:
continue
key = entry[0]
norm = re.sub(r'\s+', '', key).lower()
key_map[norm] = f"{prefix}-entry{idx}" if prefix else f"entry{idx}"
lines = []
total = len(rev)
visible_count = sum(1 for e in rev if e and 'theorem' not in e)
use_global = global_counter is not None and global_total is not None
step_num = 0
# Highlight the *achieved* goal: the LAST non-theorem row in display
# order whose expression matches goal_key_norm (when given) or the
# last non-theorem row outright (when no goal is named — the
# convention is that the latest derivation in display = the row that
# closes the proof).
#
# Two reasons to scan from the end:
# (a) The same expression may appear multiple times in a stack
# (e.g. used as a premise earlier, then re-derived). Picking
# the EARLIEST match would highlight the row the proof
# *consumes*, not the row the proof *produces*. Latest match
# is the produced/achieved one.
# (b) A trailing 'theorem' row (broadcast-theorem reference) at
# on-disk position 0 (= total-1 in display) was previously
# skipped by the `'theorem' in entry` filter inside the render
# loop, leaving the subproof with NO orange highlight at all.
# Backward scan with the same theorem-filter skips those rows
# and finds the real conclusion.
highlight_idx = None
if total > 0:
for i in range(total - 1, -1, -1):
entry = rev[i]
if not entry:
continue
if 'theorem' in entry:
continue
if goal_key_norm and _norm_expr(entry[0]) != goal_key_norm:
continue
highlight_idx = i
break
for idx, entry in enumerate(rev):
if 'theorem' in entry:
continue
if not entry:
continue
step_num += 1
if use_global:
global_counter[0] += 1
step_badge = f"<span class='step-badge'>({global_counter[0]}/{global_total})</span>"
else:
step_badge = f"<span class='step-badge'>({step_num}/{visible_count})</span>"
key_expr = entry[0]
key_validity = entry[1] if len(entry) > 1 else ""
explanation = entry[2] if len(entry) > 2 else ""
norm_key = re.sub(r'\s+', '', key_expr).lower()
anchor = key_map.get(norm_key, "")
if highlight_idx is not None and idx == highlight_idx:
first, *rest = key_expr.split(' ', 1)
first_html = wrap_clickable(first)
if first_html.startswith('<a '):
first_html = first_html.replace(
'<span class="clickable"',
'<span class="clickable" style="color:#EF9F27 !important; font-weight:bold !important;"',
1
)
else:
first_html = f'<span class="clickable" style="color:#EF9F27; font-weight:bold">{first_html}</span>'
if rest:
rest_html = wrap_clickable(rest[0])
key_html = f"{first_html} {rest_html}"
else:
key_html = first_html
else:
key_html = wrap_clickable(key_expr)
# Expression diff highlight for equality1 steps
if explanation == "equality1" and len(entry) > 3:
key_html = _diff_highlight_html(key_html, key_expr, entry[3])
if key_validity:
key_html += _format_validity_tag(key_validity, namespace_anchor_map)
# Collect dependency anchor IDs for hover-highlighting
dep_ids = []
for di in range(3, len(entry), 2):
d_expr = entry[di]
d_norm = re.sub(r'\s+', '', d_expr).lower()
if d_norm in key_map:
dep_ids.append(key_map[d_norm])
elif d_norm in external_anchor_map:
dep_ids.append(external_anchor_map[d_norm])
deps_attr = f" data-deps=\"{' '.join(dep_ids)}\"" if dep_ids else ""
parts = [f"<span id='{anchor}'{deps_attr}>{key_html}</span>"]
if explanation:
tag_key = explanation.lower().strip()
tag_anchor = tag_key.replace(" ", "-").replace("(", "").replace(")", "")
escaped = html.escape(explanation)
parts.append(
f"<a href='tags.html#{tag_anchor}' class='proof-tag' "
f"data-tag='{html.escape(tag_key, quote=True)}'>"
f"<b>{escaped}</b></a>"
)
if explanation == "validity name":
if len(entry) > 3:
rhs_html = wrap_clickable(entry[3])
# Goal-alias expression follows the unified rule too: it
# links to the row where the goal is on the left side at
# the matching namespace, via chapter_ns_map / key_map.
# No subproof-anchor wrap.
rhs_norm = re.sub(r'\s+', '', entry[3] or '').lower()
rhs_ns_norm = re.sub(r'\s+', '', entry[4] if len(entry) > 4 else '').lower()
rhs_key = (rhs_norm, rhs_ns_norm)
if chapter_ns_map and rhs_key in chapter_ns_map:
rhs_html = (f"<a href='#{chapter_ns_map[rhs_key]}' "
f"style='text-decoration:none'>{rhs_html}</a>")
elif rhs_norm in key_map:
rhs_html = (f"<a href='#{key_map[rhs_norm]}' "
f"style='text-decoration:none'>{rhs_html}</a>")
if len(entry) > 4 and entry[4]:
rhs_html += _format_validity_tag(entry[4], namespace_anchor_map)
parts.append(rhs_html)
content_html = " ".join(parts)
if cursor_index is not None and idx == cursor_index:
content_html = (
f"<span style='background-color:#3A3520; padding:4px; "
f"border-radius:4px'>{content_html}</span>"
)
if anchor:
step_badge_html = (
f"<a href='#{anchor}' class='step-badge-link' "
f"title='Permalink to this step'>{step_badge}</a>"
)
else:
step_badge_html = step_badge
line_html = f"<div class='proof-line'>{step_badge_html}<span class='proof-line-content'>{content_html}</span></div>"
lines.append(line_html)
continue
for i in range(3, len(entry), 2):
ing_expr = entry[i]
ing_val = entry[i + 1] if i + 1 < len(entry) else ""
norm_ref = re.sub(r'\s+', '', ing_expr).lower()
ref_html = wrap_clickable(ing_expr)
# --- NEW: Highlight integration target ---
is_integration_target = (explanation == "expansion for integration" and i == 3)
if is_integration_target:
# 1. Strip away the clickable <span> and data attributes to get plain text
clean_text = re.sub(r'<[^>]+>', '', ref_html)
# 2. Re-wrap in magenta span, right-clickable for integration goal explanation
ref_html = f'<span class="integration-goal-label" style="color:#E879F9 !important; font-weight:bold !important;">"{clean_text}"</span>'
# -----------------------------------------
# Unified link rule: any expression citation jumps to the row
# in this chapter where that expression appears as the LEFT
# SIDE (key) under a matching namespace scope. Lookup priority:
# 1. chapter_ns_map[(expr_norm, ns_norm)] — exact (expression,
# namespace) match across the whole chapter.
# 2. key_map[expr_norm] — same-subproof local key map (back-
# compat fallback for entries not yet rolled into the
# chapter-wide map).
# 3. external_anchor_map[expr_norm] — externally-provided
# theorem head minted as a chapter row.
# 4. plain text — no in-page derivation row exists.
# Subproof-anchor fallback (jump to subproof title) is gone:
# the user's directive is "all links point not to subproofs
# but to where expressions are on left side with their
# namespace scope". Subproof-title jumps stay only for
# namespace-tag chips (yellow validity tag) via
# _format_validity_tag, which doesn't pass through this block.
ing_ns_norm = re.sub(r'\s+', '', ing_val).lower() if ing_val else ''
ns_key = (norm_ref, ing_ns_norm)
if is_integration_target:
linked_ref = ref_html
elif chapter_ns_map and ns_key in chapter_ns_map:
linked_ref = f"<a href='#{chapter_ns_map[ns_key]}' style='text-decoration:none'>{ref_html}</a>"
elif norm_ref in key_map:
linked_ref = f"<a href='#{key_map[norm_ref]}' style='text-decoration:none'>{ref_html}</a>"
elif norm_ref in external_anchor_map:
linked_ref = f"<a href='#{external_anchor_map[norm_ref]}' style='text-decoration:none'>{ref_html}</a>"
else:
linked_ref = ref_html
if ing_val:
linked_ref += _format_validity_tag(ing_val, namespace_anchor_map)
parts.append(linked_ref)
content_html = " ".join(parts)
if cursor_index is not None and idx == cursor_index:
content_html = (
f"<span style='background-color:#3A3520; padding:4px; "
f"border-radius:4px'>{content_html}</span>"
)
# Wrap the step badge in an in-page permalink to the row's
# own anchor when one exists. Click → URL hash updates to
# `#<row_id>` so the reader can copy-link to a specific line
# ('see line 47 of chapter 1209'). The cursor:pointer on
# .step-badge-link makes the affordance obvious without
# changing the badge's visual weight.
if anchor:
step_badge_html = (
f"<a href='#{anchor}' class='step-badge-link' "
f"title='Permalink to this step'>{step_badge}</a>"
)
else:
step_badge_html = step_badge
line_html = f"<div class='proof-line'>{step_badge_html}<span class='proof-line-content'>{content_html}</span></div>"
lines.append(line_html)
if len(entry) > 2 and entry[2] == 'implication':
if len(entry) > 3:
helper_list = [entry[0], "implication", entry[3]]
for k in range(5, len(entry), 2):
helper_list.append(entry[k])
impl_text = visu_helpers.format_implication(helper_list)
if impl_text:
impl_html = (
f"<div class='implication readable-grey'>"
f"{_htmlify_readable(_strip_i_prefix(impl_text))}</div>"
)
lines.append(impl_html)
if len(entry) > 2 and entry[2] == 'reformulated from':
if len(entry) > 3:
helper_list = [entry[0], "reformulated from", entry[3]]
reformulated_text = visu_helpers.format_reformulation(helper_list)
reformulated_html = (
f"<div class='reformulated readable-grey'>"
f"{_htmlify_readable(_strip_i_prefix(reformulated_text))}</div>"
)
lines.append(reformulated_html)
if len(entry) > 2 and entry[2] == 'incubator back reformulation':
if len(entry) > 3:
source_readable = visu_helpers.make_readable_title(entry[3])
back_ref_readable = visu_helpers.make_readable_title(entry[0])
back_ref_text = f"{back_ref_readable} back-reformulated from {source_readable}"
back_ref_html = (
f"<div class='readable-grey'>"
f"{_htmlify_readable(_strip_i_prefix(back_ref_text))}</div>"
)
lines.append(back_ref_html)
return "\n".join(lines)
def extract_args(s: str) -> list[str]:
# same pattern as before
pattern = r'(?<=[\[,])([^,\[\]]+)(?=[\],])'
all_subs = re.findall(pattern, s)
# remove duplicates while preserving order
return list(dict.fromkeys(all_subs))
def _diff_highlight_html(result_html: str, result_expr: str, source_expr: str) -> str:
"""Highlight arguments in result_html that differ from source_expr."""
# Extract bracket structure: name[arg1,arg2,...] from both
r_match = re.match(r'^!?\((\w+)\[([^\]]+)\]\)$', result_expr.strip())
s_match = re.match(r'^!?\((\w+)\[([^\]]+)\]\)$', source_expr.strip())
if not r_match or not s_match:
return result_html
if r_match.group(1) != s_match.group(1):
return result_html # different expression name
r_args = r_match.group(2).split(',')
s_args = s_match.group(2).split(',')
if len(r_args) != len(s_args):
return result_html
# Find changed args
changed = {r_args[i] for i in range(len(r_args)) if r_args[i] != s_args[i]}
if not changed:
return result_html
# Wrap changed arg text in highlight spans — text segments only (skip HTML tags/attributes)
# Search for both raw form (v2, i4) and i-stripped form (4) since display strips i-prefix
for arg in changed:
variants = [html.escape(arg)]
if re.match(r'^i\d+$', arg):
variants.append(html.escape(arg[1:])) # stripped form: i4 -> 4
for escaped in variants:
pat = re.compile(r'(?<=[,\[])(' + re.escape(escaped) + r')(?=[,\]])')
parts = re.split(r'(<[^>]+>)', result_html)
result_html = ''.join(
pat.sub(r'<span class="arg-changed">\1</span>', p) if not p.startswith('<') else p
for p in parts
)
return result_html
def _strip_u_prefixes(token: str) -> str:
out = token
while out.startswith("u_"):
out = out[2:]
return out
def _looks_like_internal_var_token(token: str) -> bool:
"""
Internal proof-engine variable-ish names we want to rename to v<number>.