nx_symmetry_group.nx source
↩ module page · 311 lines · 10533 B
1// nx_symmetry_group.nx -- multi-axis symmetry detector.
2//
3// Generalizes nx_compose_h_symmetry + nx_compose_v_symmetry from
4// horizontal/vertical bilateral to:
5// * diagonal mirror (axis x = y)
6// * anti-diagonal mirror (axis x = -y)
7// * 180-degree rotational
8// * 90-degree rotational (square only)
9// * translational periodic
10//
11// Returns per-axis Q10 scores + the sealed-enum kind of the dominant
12// symmetry + a composite verdict. Multiple axes can fire concurrently
13// (a square checkerboard has H + V + DIAG + 4_FOLD + TRANSLATIONAL all
14// near max); the report carries them all so downstream tooling sees
15// the full symmetry group, not just the maximum.
16//
17// Cross-modal kernel: the algorithms here operate on any indexed
18// array. Today's signature takes *Image, but the substrate will
19// later generalize to spectrograms, attention heatmaps, terrain
20// arrays, palindrome detection over token streams, etc.
21//
22// Sealed-enum SymmetryKind (gap-2 mitigation via constants):
23// NX_SYM_NONE 0
24// NX_SYM_H_BILATERAL 1
25// NX_SYM_V_BILATERAL 2
26// NX_SYM_DIAGONAL 3
27// NX_SYM_ANTIDIAGONAL 4
28// NX_SYM_2_FOLD_ROT 5
29// NX_SYM_4_FOLD_ROT 6
30// NX_SYM_TRANSLATIONAL 7
31//
32// genealogy_id: weyl_1952_symmetry + tyler_1995_visual_symmetry +
33// wagemans_1995_psychophysics_symmetry
34// lineage_id: multi_axis_symmetry_group_q10
35
36// nx_safety_envelope:
37// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
38// sil_target: SIL1
39// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
40// verdict: NOT_YET_EVALUATED
41
42import "nx_syscalls.nx"
43import "nx_tier.nx"
44import "nx_image.nx"
45import "nx_compose.nx"
46
47const NX_SYM_NONE: nx_int = 0
48const NX_SYM_H_BILATERAL: nx_int = 1
49const NX_SYM_V_BILATERAL: nx_int = 2
50const NX_SYM_DIAGONAL: nx_int = 3
51const NX_SYM_ANTIDIAGONAL: nx_int = 4
52const NX_SYM_2_FOLD_ROT: nx_int = 5
53const NX_SYM_4_FOLD_ROT: nx_int = 6
54const NX_SYM_TRANSLATIONAL: nx_int = 7
55const NX_SYM_N_KINDS: nx_int = 8
56
57const NX_SYM_Q: nx_int = 1024
58// Symmetry score >= this threshold counts as "present" for that axis.
59const NX_SYM_PRESENT_THR: nx_int = 717 // ~70% Q10
60
61struct SymmetryReport {
62 h_bilateral_q10: nx_int,
63 v_bilateral_q10: nx_int,
64 diagonal_q10: nx_int,
65 antidiagonal_q10: nx_int,
66 rot_180_q10: nx_int,
67 rot_90_q10: nx_int,
68 translational_q10: nx_int,
69 translation_period: nx_int, // best matching period, 0 if none
70 dominant_kind: nx_int, // NX_SYM_* constant
71 composite_q10: nx_int, // max across all axes
72 n_axes_present: nx_int, // how many >= NX_SYM_PRESENT_THR
73}
74
75// ===== Diagonal mirror (along x = y) ===================================
76//
77// Compares pixel(x, y) to pixel(y, x). Square images only; for non-
78// square the function returns 0 (axis not measurable).
79
80func nx_sym_diagonal(gray: *Image) -> nx_int {
81 let w: nx_int = gray.width
82 let h: nx_int = gray.height
83 if w != h { return 0 }
84 var diff_sum: nx_int = 0
85 var total: nx_int = 0
86 var y: nx_int = 0
87 while y < h {
88 var x: nx_int = 0
89 while x < y { // only below diagonal
90 let av: nx_int = nx_image_get(gray, x, y, 0)
91 let bv: nx_int = nx_image_get(gray, y, x, 0)
92 var d: nx_int = av - bv
93 if d < 0 { d = -d }
94 diff_sum = diff_sum + d
95 total = total + av + bv
96 x = x + 1
97 }
98 y = y + 1
99 }
100 if total == 0 { return NX_SYM_Q }
101 let asym: nx_int = (diff_sum * NX_SYM_Q) / total
102 if asym >= NX_SYM_Q { return 0 }
103 return NX_SYM_Q - asym
104}
105
106// ===== Anti-diagonal mirror (along x = -y) =============================
107
108func nx_sym_antidiagonal(gray: *Image) -> nx_int {
109 let w: nx_int = gray.width
110 let h: nx_int = gray.height
111 if w != h { return 0 }
112 var diff_sum: nx_int = 0
113 var total: nx_int = 0
114 var y: nx_int = 0
115 while y < h {
116 var x: nx_int = 0
117 while x < w - 1 - y {
118 let av: nx_int = nx_image_get(gray, x, y, 0)
119 let bv: nx_int = nx_image_get(gray, w - 1 - y, h - 1 - x, 0)
120 var d: nx_int = av - bv
121 if d < 0 { d = -d }
122 diff_sum = diff_sum + d
123 total = total + av + bv
124 x = x + 1
125 }
126 y = y + 1
127 }
128 if total == 0 { return NX_SYM_Q }
129 let asym: nx_int = (diff_sum * NX_SYM_Q) / total
130 if asym >= NX_SYM_Q { return 0 }
131 return NX_SYM_Q - asym
132}
133
134// ===== 180-degree rotational ==========================================
135//
136// Equivalent to (H * V) symmetry; an image symmetric under 180 rotation
137// is symmetric under (x,y) -> (W-1-x, H-1-y).
138
139func nx_sym_rot_180(gray: *Image) -> nx_int {
140 let w: nx_int = gray.width
141 let h: nx_int = gray.height
142 var diff_sum: nx_int = 0
143 var total: nx_int = 0
144 var y: nx_int = 0
145 while y < h / 2 {
146 var x: nx_int = 0
147 while x < w {
148 let av: nx_int = nx_image_get(gray, x, y, 0)
149 let bv: nx_int = nx_image_get(gray, w - 1 - x, h - 1 - y, 0)
150 var d: nx_int = av - bv
151 if d < 0 { d = -d }
152 diff_sum = diff_sum + d
153 total = total + av + bv
154 x = x + 1
155 }
156 y = y + 1
157 }
158 if total == 0 { return NX_SYM_Q }
159 let asym: nx_int = (diff_sum * NX_SYM_Q) / total
160 if asym >= NX_SYM_Q { return 0 }
161 return NX_SYM_Q - asym
162}
163
164// ===== 90-degree rotational (square only) =============================
165//
166// Maps (x, y) -> (y, W-1-x). Requires square image.
167
168func nx_sym_rot_90(gray: *Image) -> nx_int {
169 let w: nx_int = gray.width
170 let h: nx_int = gray.height
171 if w != h { return 0 }
172 var diff_sum: nx_int = 0
173 var total: nx_int = 0
174 var y: nx_int = 0
175 while y < h {
176 var x: nx_int = 0
177 while x < w {
178 let av: nx_int = nx_image_get(gray, x, y, 0)
179 let bv: nx_int = nx_image_get(gray, y, w - 1 - x, 0)
180 var d: nx_int = av - bv
181 if d < 0 { d = -d }
182 diff_sum = diff_sum + d
183 total = total + av + bv
184 x = x + 1
185 }
186 y = y + 1
187 }
188 if total == 0 { return NX_SYM_Q }
189 let asym: nx_int = (diff_sum * NX_SYM_Q) / total
190 if asym >= NX_SYM_Q { return 0 }
191 return NX_SYM_Q - asym
192}
193
194// ===== Translational periodic detection ===============================
195//
196// For periods p = 2, 4, 8, ..., w/2: compare each pixel to its
197// period-shifted counterpart. Returns the BEST score across periods
198// and stores the matching period in *out_period (0 if none above
199// threshold). Cross-modal: same primitive detects musical-bar
200// repetition over a time-series, prose-cadence over token streams.
201
202func nx_sym_translational(gray: *Image, out_period: *i64) -> nx_int {
203 let w: nx_int = gray.width
204 let h: nx_int = gray.height
205 var best_score: nx_int = 0
206 var best_period: nx_int = 0
207 var p: nx_int = 2
208 while p <= w / 2 {
209 var diff_sum: nx_int = 0
210 var total: nx_int = 0
211 var y: nx_int = 0
212 while y < h {
213 var x: nx_int = 0
214 while x < w - p {
215 let av: nx_int = nx_image_get(gray, x, y, 0)
216 let bv: nx_int = nx_image_get(gray, x + p, y, 0)
217 var d: nx_int = av - bv
218 if d < 0 { d = -d }
219 diff_sum = diff_sum + d
220 total = total + av + bv
221 x = x + 1
222 }
223 y = y + 1
224 }
225 var score: nx_int = 0
226 if total == 0 {
227 score = NX_SYM_Q
228 } else {
229 let asym: nx_int = (diff_sum * NX_SYM_Q) / total
230 if asym < NX_SYM_Q { score = NX_SYM_Q - asym }
231 }
232 if score > best_score {
233 best_score = score
234 best_period = p
235 }
236 p = p * 2
237 }
238 out_period[0] = best_period
239 return best_score
240}
241
242// ===== Composite =====================================================
243//
244// Runs all axes, picks the dominant kind, counts how many are present.
245
246func nx_symmetry_compute(gray: *Image, report: *SymmetryReport) -> nx_int {
247 report.h_bilateral_q10 = nx_compose_h_symmetry(gray)
248 report.v_bilateral_q10 = nx_compose_v_symmetry(gray)
249 report.diagonal_q10 = nx_sym_diagonal(gray)
250 report.antidiagonal_q10 = nx_sym_antidiagonal(gray)
251 report.rot_180_q10 = nx_sym_rot_180(gray)
252 report.rot_90_q10 = nx_sym_rot_90(gray)
253 let period_buf: *i64 = (sys_mmap(NX_SIZEOF_NX_INT)) as *i64
254 report.translational_q10 = nx_sym_translational(gray, period_buf)
255 report.translation_period = period_buf[0]
256
257 // Find dominant kind: highest scoring axis.
258 var best: nx_int = 0
259 var kind: nx_int = NX_SYM_NONE
260
261 if report.h_bilateral_q10 > best {
262 best = report.h_bilateral_q10
263 kind = NX_SYM_H_BILATERAL
264 }
265 if report.v_bilateral_q10 > best {
266 best = report.v_bilateral_q10
267 kind = NX_SYM_V_BILATERAL
268 }
269 if report.diagonal_q10 > best {
270 best = report.diagonal_q10
271 kind = NX_SYM_DIAGONAL
272 }
273 if report.antidiagonal_q10 > best {
274 best = report.antidiagonal_q10
275 kind = NX_SYM_ANTIDIAGONAL
276 }
277 if report.rot_180_q10 > best {
278 best = report.rot_180_q10
279 kind = NX_SYM_2_FOLD_ROT
280 }
281 if report.rot_90_q10 > best {
282 best = report.rot_90_q10
283 kind = NX_SYM_4_FOLD_ROT
284 }
285 if report.translational_q10 > best {
286 best = report.translational_q10
287 kind = NX_SYM_TRANSLATIONAL
288 }
289 report.composite_q10 = best
290 report.dominant_kind = kind
291
292 // Count axes meeting the present-threshold.
293 var n: nx_int = 0
294 if report.h_bilateral_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
295 if report.v_bilateral_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
296 if report.diagonal_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
297 if report.antidiagonal_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
298 if report.rot_180_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
299 if report.rot_90_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
300 if report.translational_q10 >= NX_SYM_PRESENT_THR { n = n + 1 }
301 report.n_axes_present = n
302
303 return 0
304}
305
306// Sealed-enum validity check (gap-2 mitigation pattern).
307func nx_sym_kind_is_valid(kind: nx_int) -> nx_int {
308 if kind < 0 { return 0 }
309 if kind >= NX_SYM_N_KINDS { return 0 }
310 return 1
311}