File size: 41,582 Bytes
ad91e1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
--- a/cael_unified/__init__.py
+++ b/cael_unified/__init__.py
@@ -1,5 +1,5 @@
-"""CAEL 0.3.9.1: non-neural conversation runtime with the unchanged 0.3.7 resource bundle."""
-__version__ = '0.3.9.1'
+"""CAEL 0.4.0 core-engine candidate; not a published or deployed release."""
+__version__ = '0.4.0'
 
 
 def __getattr__(name):
--- a/cael_unified/_bounded_python_help.py
+++ b/cael_unified/_bounded_python_help.py
@@ -48,5 +48,9 @@
 
     verify.sandbox_function = denied
     apply = verify.apply
-    verify.apply = lambda text, answer: apply(text, answer, record=False)
+    def unrecorded_apply(text, answer, record=False):
+        # Preserve the checker's call contract for orchestrators that explicitly
+        # request no recording. Keep the bounded policy's no-record behaviour.
+        return apply(text, answer, record=False)
+    verify.apply = unrecorded_apply
 
--- a/cael_unified/assistant.py
+++ b/cael_unified/assistant.py
@@ -181,6 +181,15 @@
             # spelling repair, fact route or web lookup can read "CAEL" as someone else (self_identity.py, 0.3.8 fix).
             from . import self_identity
             answer = self_identity.reply(messages) if mode in ('auto', 'chat') else None
+            if (answer is None and mode in ('auto', 'chat') and isinstance(messages, list)
+                    and messages and len(messages)<=MAX_MESSAGES
+                    and all(isinstance(m,dict) and m.get('role') in ('user','assistant','system')
+                            and isinstance(m.get('content'),str) for m in messages)
+                    and sum(len(m['content']) for m in messages)<=MAX_CHARACTERS
+                    and messages[-1].get('role') == 'user' and type(n) is int and 1<=n<=8
+                    and (seed is None or type(seed) is int and 0<=seed<2**31)):
+                from .local_automata import reply as automaton_reply
+                answer = automaton_reply(messages[-1].get('content'))
             # A complete, single-request symbolic document owns its literal
             # symbols before dialogue acts, spelling and memory heuristics.
             if (answer is None and mode in ('auto','chat') and isinstance(messages,list)
--- a/cael_unified/bundle.py
+++ b/cael_unified/bundle.py
@@ -8,8 +8,8 @@
 import zipfile
 
 ROOT = Path(__file__).resolve().parents[1]
-VERSION = '0.3.9.1'
-# 0.3.9.1 adds native interpretation; the public resource bundle remains byte-identical 0.3.7.
+VERSION = '0.4.0'
+# Core algorithms evolve in source; inherited public assets remain immutable.
 BUNDLE_VERSION = '0.3.7'
 SUPPORTED = {'0.2.1', '0.2.2', '0.2.3', '0.2.4', '0.2.5', '0.2.6', '0.2.7', '0.2.8', '0.2.9', '0.3', '0.3.1', '0.3.2', '0.3.3', '0.3.4', '0.3.5', '0.3.6', '0.3.7', '0.3.8'}
 DEFAULT_BUNDLE = ROOT / 'models' / f'cael-{BUNDLE_VERSION}.cael'
--- a/cael_unified/checked_tasks.py
+++ b/cael_unified/checked_tasks.py
@@ -172,7 +172,7 @@
     m=re.fullmatch(r'(?is)([^:.\n]{1,140}):\s*([^:.\n]+)\.\s*(.+)',text)
     if not m or not re.match(r'(?i)^(?:items|objects|participants|on |in |at |there (?:are|stand) )',m[1]):return None
     names=[clean(p.strip()) for p in re.split(r',\s*(?:and\s+)?|\s+and\s+',m[2])]
-    if not 2<=len(names)<=8 or len(set(names))!=len(names):raise Unclear('Use two to eight distinct explicitly listed objects.')
+    if not 2<=len(names)<=12 or len(set(names))!=len(names):raise Unclear('Use two to twelve distinct explicitly listed objects.')
     declared=re.search(r'(?i)\b('+ '|'.join(NUMBERS)+r'|\d+)\b',m[1])
     if declared and _number(declared[1])!=len(names):raise Unclear('The stated object count and complete item list disagree.')
     parts=[p.strip() for p in re.split(r'[.!?]\s*|\n+',m[3]) if p.strip()]
--- a/cael_unified/constraint_automaton.py
+++ b/cael_unified/constraint_automaton.py
@@ -0,0 +1,76 @@
+"""Finite-domain constraint propagation with exhaustive bounded witnesses.
+
+Only explicit trusted predicates are called. No generated code, neural model,
+training answers or benchmark-dependent rule selection.
+"""
+from itertools import product
+
+
+class SearchLimit(ValueError):
+    pass
+
+
+def enumerate_orders(names, constraints, holds, max_nodes=250000, max_checks=3000000,
+                     max_solutions=50000):
+    names = tuple(names)
+    if not 2 <= len(names) <= 12 or len(set(names)) != len(names):
+        raise ValueError('Use 2 to 12 distinct items')
+    scopes = [tuple(dict.fromkeys(c[1:2] + (() if c[0].removeprefix('not_') == 'at' else c[2:])))
+              for c in constraints]
+    if any(any(n not in names for n in scope) for scope in scopes):
+        raise ValueError('A constraint references an unlisted item')
+    stats = dict(nodes=0, predicate_checks=0, propagations=0, complete=False)
+    solutions = []
+
+    def propagate(domains):
+        while True:
+            before = tuple(tuple(sorted(domains[n])) for n in names)
+            singles = [next(iter(v)) for v in domains.values() if len(v) == 1]
+            if len(singles) != len(set(singles)):
+                return False
+            used = set(singles)
+            for name, values in domains.items():
+                if len(values) > 1: values.difference_update(used)
+                if not values: return False
+            # Hall's union necessary condition, alongside singleton all-different.
+            if len(set().union(*domains.values())) < len(names): return False
+            for constraint, scope in zip(constraints, scopes):
+                supported = {name: set() for name in scope}
+                for values in product(*(sorted(domains[n]) for n in scope)):
+                    stats['predicate_checks'] += 1
+                    if stats['predicate_checks'] > max_checks:
+                        raise SearchLimit('Constraint propagation work budget reached')
+                    if len(values) != len(set(values)): continue
+                    if holds(constraint, dict(zip(scope, values))):
+                        for name, value in zip(scope, values): supported[name].add(value)
+                for name in scope:
+                    domains[name].intersection_update(supported[name])
+                    if not domains[name]: return False
+            stats['propagations'] += 1
+            if before == tuple(tuple(sorted(domains[n])) for n in names): return True
+
+    def visit(domains):
+        stats['nodes'] += 1
+        if stats['nodes'] > max_nodes: raise SearchLimit('Ordering search node budget reached')
+        if not propagate(domains): return
+        pending = [n for n in names if len(domains[n]) > 1]
+        if not pending:
+            positions = {n: next(iter(domains[n])) for n in names}
+            order = tuple(sorted(names, key=positions.get))
+            solutions.append(order)
+            if len(solutions) > max_solutions:
+                raise SearchLimit('Too many valid orders for complete enumeration')
+            return
+        name = min(pending, key=lambda n: (len(domains[n]), names.index(n)))
+        for value in sorted(domains[name]):
+            child = {n: set(v) for n, v in domains.items()}
+            child[name] = {value}
+            visit(child)
+
+    visit({name: set(range(len(names))) for name in names})
+    # Preserve the old public ordering of witnesses, independent of MRV order.
+    rank = {name: i for i, name in enumerate(names)}
+    solutions.sort(key=lambda row: tuple(rank[n] for n in row))
+    stats['complete'] = True
+    stats['solutions'] = len(solutions)
+    return tuple(solutions), stats
--- a/cael_unified/count_language.py
+++ b/cael_unified/count_language.py
@@ -66,9 +66,12 @@
 
     def neighbours(self,context,limit=128):
         np=self.np
-        if context in self.cache:
-            self.cache.move_to_end(context)
-            return self.cache[context]
+        if type(limit) is not int or not 1<=limit<=2048:
+            raise ValueError('Neighbour limit must be an integer from 1 to 2048')
+        cache_key=(context,limit)
+        if cache_key in self.cache:
+            self.cache.move_to_end(cache_key)
+            return self.cache[cache_key]
         query=self.query_features(context)
         # Both stored/query rows are L2 normalized; this is sparse cosine recall.
         from sklearn.preprocessing import normalize
@@ -76,13 +79,17 @@
         hits=(query@self.contexts.T).tocsr()
         if hits.nnz:
             take=min(limit,hits.nnz)
-            selection=np.argpartition(hits.data,-take)[-take:]
+            threshold=np.partition(hits.data,-take)[-take]
+            greater=np.flatnonzero(hits.data>threshold)
+            equal=np.flatnonzero(hits.data==threshold)
+            equal=equal[np.argsort(hits.indices[equal],kind='stable')]
+            selection=np.concatenate((greater,equal[:take-len(greater)]))
             order=np.lexsort((hits.indices[selection],-hits.data[selection]))
             selection=selection[order]
             result=(hits.indices[selection],hits.data[selection])
         else:
             result=(np.array([],dtype=np.int64),np.array([],dtype=np.float64))
-        self.cache[context]=result
+        self.cache[cache_key]=result
         if len(self.cache)>32:
             self.cache.popitem(last=False)
         return result
@@ -90,7 +97,7 @@
     def conditional(self,context,settings=None):
         np=self.np
         settings=settings or self.settings
-        indices,similarities=self.neighbours(context)
+        indices,similarities=self.neighbours(context,limit=max(128,settings['neighbours']))
         count=min(len(indices),settings['neighbours'])
         if count:
             weights=similarities[:count].astype(np.float64)**settings['recall_power']
--- a/cael_unified/event_memory.py
+++ b/cael_unified/event_memory.py
@@ -43,14 +43,18 @@
 def key(entity,attribute):return clean(entity).casefold()+'\x1f'+clean(attribute).casefold()
 
 def resolve(state,cell,seen=None):
-    seen=set() if seen is None else seen
-    if cell in seen or len(seen)>128:raise ValueError('Circular or too-deep reference')
-    seen.add(cell)
-    value=state.get(cell,UNKNOWN)
-    if isinstance(value,dict):
-        if 'ref' in value:return resolve(state,value['ref'],seen)
-        raise ValueError('Unknown or conflicting value')
-    return value
+    # Iterative traversal supports the stated cell budget, instead of an
+    # unrelated 128-link or Python recursion ceiling. References remain lazy:
+    # changing a source affects live links but never earlier copied values.
+    seen=set() if seen is None else set(seen)
+    while True:
+        if cell in seen:raise ValueError('Circular live dependency')
+        if len(seen)>=MAX_CELLS:raise ValueError('Reference traversal exceeds the state-cell budget')
+        seen.add(cell)
+        value=state.get(cell,UNKNOWN)
+        if not isinstance(value,dict):return value
+        if 'ref' not in value:raise ValueError('Unknown or conflicting value')
+        cell=value['ref']
 
 def transition(state,slots,operation):
     """The candidate algebra used by both the trainer and runtime.
@@ -241,8 +245,8 @@
             boundary=versions[0][0] if past=='initial' else versions[-1][0]-1
             state={}
             for k,history in self.history.items():
-                prior=[v for i,v in history if i<=boundary]
-                if prior:state[k]=prior[-1]
+                offset=bisect_right(history,boundary,key=lambda entry:entry[0])
+                if offset:state[k]=history[offset-1][1]
         return resolve(state,cell)
 
 def reply(messages):
--- a/cael_unified/interpretation_relations.py
+++ b/cael_unified/interpretation_relations.py
@@ -109,6 +109,16 @@
     if not any(re.search(r'(?i)\b(?:before|after|ahead of|behind|precedes|follows)\b',p) for p in parts[:-1]):return None
     boundary(parts[-1])
     if query[0]=='unsupported':raise TaskError('The complete ordering question contains an unsupported qualifier.')
+    # Richer explicit constraints belong to the same finite-domain engine used
+    # by ordinary reasoning. Do not reject them in the precedence-only reader.
+    if re.match(r'(?i)^(?:items|people|objects|participants|runners|books|cars|order)\s*:', parts[0]) and any(
+            re.search(r'(?i)\b(?:immediately|directly|adjacent|next to|between|not|first|last|second|third|fourth|fifth)\b', p)
+            for p in parts[1:-1]):
+        from .reasoning_order import ordering
+        try: result = ordering('. '.join(parts[:-1]), parts[-1])
+        except ValueError as exc: raise TaskError(str(exc)) from exc
+        return Result(result['answer'], 'ordering', {'coverage': spans(text, parts),
+                      'engine': 'finite-domain-constraint-automaton', 'proof': result})
     names=[];edges=[]
     for raw in parts[:-1]:
         p=raw.strip()
@@ -178,6 +188,10 @@
         words=set(re.findall(r'[a-z]+',p.casefold()))
         if words and words<=header_words and words&{'log','events','entries','chronological','updates'}:continue
         boundary(p)
+        m=re.fullmatch(rf'(?i)({ATOM}?) is either in (?:the )?({ATOM}?) or (?:the )?({ATOM})',p)
+        if m:events.append(('uncertain',symbol(m[1]),symbol(m[2]),symbol(m[3])));continue
+        m=re.fullmatch(rf'(?i)({ATOM}?) is (?:not|no longer) in (?:the )?({ATOM})',p)
+        if m:events.append(('exclude',symbol(m[1]),symbol(m[2])));continue
         m=re.fullmatch(rf'(?i)({ATOM}?) (?:went|moved|relocated|walked|travelled|traveled|journeyed|returned)(?: back)? (?:to|into) (?:the )?({ATOM})',p)
         if m:events.append(('move',symbol(m[1]),symbol(m[2])));continue
         m=re.fullmatch(rf'(?i)({ATOM}?) (?:entered|visited) (?:the )?({ATOM})',p)
@@ -227,13 +241,17 @@
             if cell and cell.locations and cell.locations!={name(value)}:raise TaskError('A departure location conflicts with the preceding event state.')
             event_text=f'{name(actor)} went to {name(extra[0])}'
         elif op=='move':event_text=f'{name(actor)} went to {name(value)}'
+        elif op=='uncertain':event_text=f'{name(actor)} is either in {name(value)} or {name(extra[0])}'
+        elif op=='exclude':event_text=f'{name(actor)} is not in {name(value)}'
         elif op=='take':event_text=f'{name(actor)} got {name(value)}'
         elif op=='drop':event_text=f'{name(actor)} dropped {name(value)}'
         else:
             old=world.cells.get(name(value))
             if old and old.owner not in (None,name(actor)):raise TaskError('The giver is not the established holder of the object.')
             event_text=f'{name(actor)} gave {name(value)} to {name(extra[0])}'
-        world.apply(world.event(event_text),index);canonical.append(event_text+'.')
+        try: world.apply(world.event(event_text),index)
+        except ValueError as exc: raise TaskError(str(exc)) from exc
+        canonical.append(event_text+'.')
     target_value=query[1]
     if target_value in ('he','she','they','it'):
         referents=known_objects if query[0]=='owner' else known_actors
--- a/cael_unified/local_automata.py
+++ b/cael_unified/local_automata.py
@@ -0,0 +1,181 @@
+"""Example-induced finite local transition models, without neural inference.
+
+The inductive bias is explicit: stationary 1-D discrete updates, a bounded
+neighbourhood, exact lookup consistency and aggregate/symmetry hypotheses.
+Example-pair cross-validation ranks hypotheses. Query answers never enter fit.
+Unseen neighbourhoods use labelled empirical guesses, never claimed proofs.
+"""
+from collections import Counter, defaultdict
+from dataclasses import dataclass
+import json
+import math
+
+MAX_RADIUS = 3
+MAX_EXAMPLES = 32
+MAX_WIDTH = 512
+MAX_SYMBOL = 31
+
+
+class OutsideDomain(ValueError):
+    pass
+
+
+def row(value):
+    if type(value) is not list or not 1 <= len(value) <= MAX_WIDTH:
+        raise OutsideDomain('Expected a nonempty discrete row of at most 512 cells')
+    if any(type(v) is not int or not 0 <= v <= MAX_SYMBOL for v in value):
+        raise OutsideDomain('Cells must be integer symbols from 0 through 31')
+    return tuple(value)
+
+
+def neighbourhood(source, position, radius, boundary):
+    if boundary == 'wrap':
+        return tuple(source[(position+d) % len(source)] for d in range(-radius, radius+1))
+    if boundary == 'zero':
+        return tuple(source[position+d] if 0 <= position+d < len(source) else 0
+                     for d in range(-radius, radius+1))
+    raise OutsideDomain('Boundary must be wrap or zero')
+
+
+def signature(window, radius, kind):
+    if kind == 'left': return (window[0],)
+    if kind == 'right': return (window[-1],)
+    if kind == 'ordered': return window
+    if kind == 'multiset': return tuple(sorted(window))
+    if kind == 'sum': return (sum(window),)
+    outside = window[:radius] + window[radius+1:]
+    if kind == 'center_sum': return window[radius], sum(outside)
+    if kind == 'center_multiset': return window[radius], tuple(sorted(outside))
+    if kind == 'outer_sum': return (sum(outside),)
+    raise OutsideDomain('Unknown feature family')
+
+
+@dataclass
+class Hypothesis:
+    radius: int
+    kind: str
+    table: dict
+    counts: dict
+    validation_lnp: float
+    validation_seen: int
+    validation_cells: int
+    cost: float
+
+
+def fit_map(pairs, radius, kind, boundary):
+    table = {}; counts = Counter()
+    for source, target in pairs:
+        for position, value in enumerate(target):
+            key = signature(neighbourhood(source, position, radius, boundary), radius, kind)
+            if key in table and table[key] != value: return None
+            table[key] = value; counts[key] += 1
+    return table, counts
+
+
+def learn_transition(examples, query, boundary='wrap'):
+    """Predict a new row from demonstration pairs, retaining coverage/ambiguity.
+
+    All fitting is ephemeral and episode-local. This function accepts only
+    demonstrations and query, never a rule-table/family/label metadata object.
+    """
+    if type(examples) is not list or not 2 <= len(examples) <= MAX_EXAMPLES:
+        raise OutsideDomain('Provide 2 to 32 independent example pairs')
+    pairs = []
+    for example in examples:
+        if type(example) is not dict or set(example) != {'input', 'output'}:
+            raise OutsideDomain('Each example needs only input and output')
+        source, target = row(example['input']), row(example['output'])
+        if len(source) != len(target): raise OutsideDomain('Input/output widths must match')
+        pairs.append((source, target))
+    query = row(query)
+    if sum(len(x) for x, _ in pairs) > 16384:
+        raise OutsideDomain('Demonstration cell budget reached')
+    if boundary not in ('wrap', 'zero'): raise OutsideDomain('Unknown boundary')
+    # Contradictory full-row evidence is invalid even if a larger neighbourhood
+    # could hide a collision. Retain ambiguity instead of fitting the last row.
+    seen_rows = {}
+    for source, target in pairs:
+        if source in seen_rows and seen_rows[source] != target:
+            raise OutsideDomain('The demonstrations give conflicting outputs for the same input')
+        seen_rows[source] = target
+    symbols = max([*query, *(v for x, y in pairs for v in (*x, *y))]) + 1
+    symbols = max(2, symbols)
+    global_counts = Counter(v for _, target in pairs for v in target)
+    fallback = max(range(symbols), key=lambda v: (global_counts[v], -v))
+    models = []
+    families = [(0, 'ordered')]
+    families += [(r, kind) for r in range(1, MAX_RADIUS+1)
+                 for kind in ('left', 'right', 'sum', 'outer_sum', 'center_sum', 'multiset', 'center_multiset', 'ordered')]
+    for radius, kind in families:
+        fitted = fit_map(pairs, radius, kind, boundary)
+        if fitted is None: continue
+        table, counts = fitted
+        lnp = 0.0; covered = 0; total = 0
+        # Hold out entire pairs, not individual neighbouring cells, to avoid
+        # scoring an example against its own transitions.
+        for excluded, (source, target) in enumerate(pairs):
+            rest = pairs[:excluded] + pairs[excluded+1:]
+            partial, witnesses = fit_map(rest, radius, kind, boundary)
+            prior = Counter(v for _, output in rest for v in output)
+            prior_total = sum(prior.values())
+            for position, value in enumerate(target):
+                key = signature(neighbourhood(source, position, radius, boundary), radius, kind)
+                if key in partial:
+                    covered += 1
+                    probability = (witnesses[key] + 0.5) / (witnesses[key] + 0.5*symbols)
+                else:
+                    probability = (prior[value]+0.5) / (prior_total+0.5*symbols)
+                lnp += math.log(probability); total += 1
+        description = (2*radius+1 if kind == 'ordered' else radius+2 if 'multiset' in kind else 2)
+        cost = math.log2(2+len(table)) + 0.25*description
+        models.append(Hypothesis(radius, kind, table, counts, lnp, covered, total, cost))
+    models.sort(key=lambda m: (-(m.validation_lnp-0.10*m.cost), m.cost, m.radius, m.kind))
+    # No silent switch to an external model or query-label-derived rule. If
+    # local stationarity cannot fit, emit an explicit empirical-only result.
+    if not models:
+        return dict(prediction=[fallback]*len(query), supported=[False]*len(query),
+                    alternatives=[list(range(symbols)) for _ in query],
+                    status='empirical-only', fitted_hypotheses=0, selected=None,
+                    coverage=0.0, neural=False, persistent_learning=False)
+    selected = models[0]
+    prediction = []; supported = []; alternatives = []
+    for position in range(len(query)):
+        key = signature(neighbourhood(query, position, selected.radius, boundary), selected.radius, selected.kind)
+        known = key in selected.table
+        # Back off through independently consistent hypotheses when the chosen
+        # compressed representation lacks a query signature.
+        choices = []
+        for model in models:
+            signature_key = signature(neighbourhood(query, position, model.radius, boundary), model.radius, model.kind)
+            if signature_key in model.table: choices.append(model.table[signature_key])
+        value = selected.table[key] if known else choices[0] if choices else fallback
+        prediction.append(value); supported.append(known)
+        alternatives.append(sorted(set(choices)) if choices else list(range(symbols)))
+    return dict(prediction=prediction, supported=supported, alternatives=alternatives,
+                status='inferred' if all(supported) else 'partially-supported',
+                coverage=sum(supported)/len(query), fitted_hypotheses=len(models),
+                selected=dict(radius=selected.radius, features=selected.kind,
+                              observed_signatures=len(selected.table),
+                              held_pair_coverage=selected.validation_seen/max(1, selected.validation_cells),
+                              held_pair_log_likelihood=selected.validation_lnp),
+                disagreeing_cells=sum(len(v)>1 for v in alternatives),
+                neural=False, persistent_learning=False,
+                guarantee='Prediction under an inferred finite local hypothesis, not a uniquely identified general rule')
+
+
+def reply(text):
+    prefix = 'Infer discrete transition:'
+    if not isinstance(text, str) or not text.lstrip().startswith(prefix): return None
+    payload = text.lstrip()[len(prefix):].strip()
+    try:
+        if len(payload) > 200000: raise OutsideDomain('Structured task length limit')
+        task = json.loads(payload)
+        if type(task) is not dict or not {'examples', 'query'} <= set(task) or set(task)-{'examples','query','boundary'}:
+            raise OutsideDomain('Use examples, query, and optional boundary only')
+        result = learn_transition(task['examples'], task['query'], task.get('boundary', 'wrap'))
+    except (ValueError, TypeError, RecursionError) as exc:
+        return dict(route='chat/automaton-clarify', response='Please clarify the transition task: '+str(exc),
+                    sources=[], automaton={'status':'invalid-or-unsupported', 'neural':False})
+    return dict(route='chat/automaton-induction', response=json.dumps(result['prediction']), sources=[],
+                automaton=result, details='Example-induced discrete local transitions. '+result.get('guarantee',
+                    'Only an empirical output-frequency guess; no supported local rule was found.'))
--- a/cael_unified/reasoning_logic.py
+++ b/cael_unified/reasoning_logic.py
@@ -1,5 +1,6 @@
 """Bounded Horn-rule graph automaton with explicit proof support and open-world answers."""
 from dataclasses import dataclass
+from collections import defaultdict, deque
 import re
 
 
@@ -89,33 +90,45 @@
     def close(self, context):
         facts, rules = self.parse(context)
         entities = sorted({fact[0] for fact, _ in facts})
-        if len(entities)*len(rules)>4096:
+        if len(entities)*len(rules)>32768:
             raise Unparsed('Too many grounded rule cells')
-        active = {fact for fact, _ in facts}
-        proof = {fact: {index} for fact, index in facts}
-        depths = {fact: 0 for fact, _ in facts}
+        active, proof, depths = set(), {}, {}
+        for fact, index in facts:
+            if fact not in active:
+                active.add(fact); proof[fact] = {index}; depths[fact] = 0
         grounded = [(tuple((entity, p, neg) for p, neg in body), tuple((entity, p, neg) for p, neg in head), index)
                     for entity in entities for body, head, index in rules]
-        # Bipartite graph CA: rule cells read only connected premise fact cells;
-        # conclusion fact cells read only connected rule cells. No neural weights.
+        # Sparse graph CA: changes wake only connected rule cells. Proof
+        # improvements propagate too; truth and evidence share the fixed point.
+        readers = defaultdict(list)
+        for rule_id, (body, head, index) in enumerate(grounded):
+            for atom in set(body): readers[atom].append(rule_id)
+        agenda = deque(sorted(active)); queued = set(active)
         table = self.model.get('and_table', {})
-        for tick in range(24):
-            next_active = set(active)
-            next_proof, next_depth = dict(proof), dict(depths)
-            for body, head, index in grounded:
+        work = 0
+        def rank(depth, support):
+            return depth, len(support), tuple(sorted(support))
+        while agenda:
+            changed = agenda.popleft(); queued.remove(changed)
+            for rule_id in readers[changed]:
+                work += 1
+                if work > 250000:
+                    raise Unparsed('The sparse graph CA exceeded its rule-transition budget')
+                body, head, index = grounded[rule_id]
                 mask = sum((1 << i) for i, atom in enumerate(body) if atom in active)
                 if not table.get(f'{len(body)}:{mask}', 0):
                     continue
                 support = {index}.union(*(proof[atom] for atom in body))
                 depth = 1 + max(depths[atom] for atom in body)
                 for atom in head:
-                    next_active.add(atom)
-                    if atom not in next_depth or depth < next_depth[atom]:
-                        next_proof[atom], next_depth[atom] = support, depth
-            if next_active == active:
-                return active, proof, depths
-            active, proof, depths = next_active, next_proof, next_depth
-        raise Unparsed('The proof exceeded 24 rule-application rounds')
+                    if atom not in active or rank(depth, support) < rank(depths[atom], proof[atom]):
+                        active.add(atom); proof[atom], depths[atom] = support, depth
+                        if atom not in queued:
+                            agenda.append(atom); queued.add(atom)
+        self.last_stats = dict(engine='sparse-horn-graph-ca', grounded_rules=len(grounded),
+                               rule_transitions=work, active_fact_cells=len(active),
+                               maximum_proof_depth=max(depths.values(), default=0))
+        return active, proof, depths
 
     def query(self, context, question):
         active, proofs, depths = self.close(context)
--- a/cael_unified/reasoning_order.py
+++ b/cael_unified/reasoning_order.py
@@ -1,14 +1,13 @@
 """Bounded integer constraints over one explicit, tie-free ordering.
 
-Checks all permutations, distinguishes possibility from necessity, and requires
+Propagates finite domains, distinguishes possibility from necessity, and requires
 complete surface clauses. No learned weights, network calls or benchmark data.
 """
 from functools import lru_cache
-from itertools import permutations
 import re
 from .reasoning_logic import Unparsed, sentences
 
-ORDINAL = dict(zip('first second third fourth fifth sixth seventh eighth'.split(), range(8)))
+ORDINAL = dict(zip('first second third fourth fifth sixth seventh eighth ninth tenth eleventh twelfth'.split(), range(12)))
 LIST = r'(?:items|people|objects|participants|runners|books|cars|order)\s*:'
 
 
@@ -92,16 +91,15 @@
     if not parts or ':' not in parts[0]:raise Unparsed('An ordering puzzle needs an explicit item list')
     items = re.sub(r'\s+and\s+', ',', parts[0].split(':',1)[1],flags=re.I)
     names = tuple(clean(item) for item in items.split(',') if clean(item))
-    if len(set(names)) != len(names) or not 2 <= len(names) <= 8:raise Unparsed('Use 2 to 8 distinct items')
+    if len(set(names)) != len(names) or not 2 <= len(names) <= 12:raise Unparsed('Use 2 to 12 distinct items')
     if any(not re.fullmatch(r'[\w]+(?:[ -][\w]+)*',name) for name in names):raise Unparsed('Unsupported item name')
     constraints = tuple(condition(part,names) for part in parts[1:])
     if len(dimensions(' '.join(parts[1:]),names))>1:
         raise Unparsed('Use one ordering dimension; age, price and spatial orders are separate')
-    solutions = []
-    for order in permutations(names):
-        positions = {name:i for i,name in enumerate(order)}
-        if all(holds(c,positions) for c in constraints):solutions.append(order)
-    return names,tuple(solutions)
+    from .constraint_automaton import enumerate_orders, SearchLimit
+    try: solutions, stats = enumerate_orders(names, constraints, holds)
+    except (ValueError, SearchLimit) as exc: raise Unparsed(str(exc)) from exc
+    return names, solutions
 
 
 def proposition(question, names):
--- a/cael_unified/reasoning_world.py
+++ b/cael_unified/reasoning_world.py
@@ -1,5 +1,6 @@
 """Local packet automaton for bounded chronological fact/ownership scenarios."""
 from dataclasses import dataclass, field
+from collections import defaultdict
 import re
 from .reasoning_logic import Unparsed, sentences
 
@@ -20,6 +21,8 @@
         self.model = model
         self.cells = {}
         self.gifts = []
+        self.carried = defaultdict(set)
+        self.transition_visits = 0
 
     def event(self, statement):
         statement = statement.lower().strip(' .')
@@ -52,6 +55,19 @@
 
     def apply(self, event, index):
         op, actor, value, *extra = event
+        if op not in ('move', 'uncertain', 'exclude', 'take', 'drop', 'give'):
+            raise Unparsed('Unknown world transition')
+        # Validate the complete transaction before mutating any cell.
+        old_actor = self.cells.get(actor)
+        old_object = self.cells.get(value) if op in ('take', 'drop', 'give') else None
+        if old_object and old_object.owner not in (None, actor):
+            raise Unparsed('The acting person is not the established holder of this object')
+        if op == 'give' and (len(extra) != 1 or extra[0] == actor):
+            raise Unparsed('A transfer requires one distinct recipient')
+        if op in ('take', 'give') and old_actor and old_actor.locations:
+            other = old_object if op == 'take' else self.cells.get(extra[0])
+            if other and other.locations and old_actor.locations.isdisjoint(other.locations):
+                raise Unparsed('The interaction conflicts with the stated locations')
         if actor not in self.cells:
             self.cells[actor] = Cell(actor, 'actor')
         if op in ('take', 'drop','give') and value not in self.cells:
@@ -61,40 +77,36 @@
                 self.cells[extra[0]]=Cell(extra[0],'actor')
             self.gifts.append((actor,value,extra[0],index))
         actor_cell = self.cells[actor]
-        packet = dict(op=op, actor=actor, value=value, extra=extra, support={index} | actor_cell.support,
-                      locations=set(actor_cell.locations))
-        rows = list(self.cells.values())
-        carriers = [None] * len(rows)
-        carriers[0] = packet
-        # A packet moves one neighbouring cell per synchronous tick. Each cell
-        # reads only its own state and its local packet, as in the story CA.
-        for _ in range(len(rows)):
-            following = [None] * len(rows)
-            for position, cell in enumerate(rows):
-                message = carriers[position]
-                if message is None:
-                    continue
-                if op in ('move', 'uncertain', 'exclude') and (cell.name == actor or cell.owner == actor):
-                    if op == 'move':
-                        self.locate(cell, {value}, {index} if cell.name == actor else cell.support | {index})
-                    elif op == 'uncertain':
-                        self.locate(cell, {value, extra[0]}, {index})
+        # Ownership edges define the local neighbourhood. No empty-slot
+        # N-by-N packet scan: only actor/owned-object cells receive the event.
+        if op in ('move', 'uncertain', 'exclude'):
+            for name in [actor, *sorted(self.carried[actor])]:
+                cell = self.cells[name]; self.transition_visits += 1
+                if op == 'move':
+                    self.locate(cell, {value}, {index} if name == actor else cell.support | {index})
+                elif op == 'uncertain':
+                    self.locate(cell, {value, extra[0]}, {index} if name == actor else cell.support | {index})
+                else:
+                    remaining = cell.locations - {value}; support = cell.support | {index}
+                    if remaining:
+                        self.locate(cell, remaining, support)
                     else:
-                        cell.locations = set()
-                        cell.excluded = {value}
-                        cell.support = {index}
-                elif op in ('take', 'drop','give') and cell.name == value:
-                    if op == 'drop' and cell.owner not in (None, actor):
-                        raise Unparsed('An actor cannot drop an object held by someone else')
-                    cell.owner = extra[0] if op=='give' else actor if op == 'take' else None
-                    self.locate(cell, message['locations'], message['support'])
-                if position + 1 < len(rows):
-                    following[position + 1] = message
-            carriers = following
+                        cell.locations = set(); cell.support = support
+                    cell.excluded.add(value)
+        else:
+            cell = self.cells[value]; self.transition_visits += 1
+            if cell.owner is not None: self.carried[cell.owner].discard(value)
+            cell.owner = extra[0] if op == 'give' else actor if op == 'take' else None
+            if cell.owner is not None: self.carried[cell.owner].add(value)
+            support = {index} | actor_cell.support
+            if op in ('drop', 'give'): support |= cell.support
+            self.locate(cell, set(actor_cell.locations), support)
 
     def query(self, context, question):
         self.cells = {}
         self.gifts = []
+        self.carried = defaultdict(set)
+        self.transition_visits = 0
         for index, statement in enumerate(sentences(context), 1):
             self.apply(self.event(statement), index)
         question = question.lower().strip(' ?.!.')
--- a/cael_unified/self_knowledge.json
+++ b/cael_unified/self_knowledge.json
@@ -1,45 +1,26 @@
 {
-  "version": "0.3.9.1",
+  "version": "0.4.0",
   "changes": [
-    "Native readers now interpret complete supported arithmetic, rule, ordering, location, string and table tasks before unrelated chat fallbacks.",
-    "Supported interpretations compile to checked symbolic operations using the existing non-neural engines; weights and the resource bundle are unchanged.",
-    "Unsupported wording or constraints can still lead to clarification or abstention rather than a guessed answer.",
-    "Installed version, change-list and capability questions use local product metadata before public web lookup."
+    "This is an isolated unpublished 0.4.0 core-engine candidate, not the current Vynly worker.",
+    "Indexed rule-graph propagation, sparse validated world transitions and finite-domain ordering replace earlier core algorithms.",
+    "A new non-neural local transition learner infers bounded discrete updates from explicit examples.",
+    "Live references are traversed iteratively; retrieval honours requested neighbour counts and deterministic ties."
   ],
   "capabilities": [
-    "Solve supported standalone arithmetic, rule chains, precedence, location updates, string operations and table reductions.",
-    "Keep supported context and explicit preferences from the messages in this chat.",
-    "Calculate exact arithmetic and recheck supported calculations.",
-    "Write and check bounded Python examples; unsupported code needs clarification.",
-    "Read public sources and show links when a supported answer can be extracted.",
-    "Explain Vynly features and help write image prompts."
+    "Execute supported exact arithmetic, rule, state, ordering and bounded Python tasks.",
+    "Infer finite local discrete transitions from explicit input/output examples, reporting empirical guesses and ambiguity.",
+    "Retain supported context from the supplied conversation while isolating independent requests."
   ],
   "limitations": [
-    "Finite readers miss unsupported wording. In the fresh 900-question study, 123 questions received no scored answer.",
-    "I still miss some phrasings, option switches, dictionary follow-ups and requests for richer examples.",
-    "My Python interpreter and language readers cover finite patterns, not arbitrary programs or English.",
-    "A retrieved source is evidence, not proof that an answer is true.",
-    "Supported forgetting is not general transcript erasure."
+    "This candidate is not published, deployed or independently evaluated.",
+    "Finite readers still miss wording and complete context; no general chatbot or Qwen superiority is established.",
+    "An inferred local transition is a hypothesis, not proof of a unique general rule.",
+    "Inherited weights, protected solvers, image CA kernels and the 0.3.7 public resource bundle are unchanged."
   ],
-  "evidence": "On one fresh 900-question synthetic study, the evaluated native candidate scored 777/900 versus 414/900 for the unchanged 0.3.9 baseline. The release preserves those task modules and adds version metadata. Known task families, maintainer/AI-assisted authoring and fixed neural letter scoring limit the result. This does not establish general chat competence or a Qwen win; Qwen was not part of this study. The evaluated count fallback was inactive; a fresh installation activates it, so that primary score is configuration-specific.",
-  "sources": [
-    {
-      "title": "CAEL 0.3.9.1 release notes",
-      "url": "https://github.com/Vovala14/CAEL/releases/tag/v0.3.9.1"
-    },
-    {
-      "title": "CAEL 0.3.9.1 model card",
-      "url": "https://huggingface.co/vynly/CAEL-0.3.9.1"
-    },
-    {
-      "title": "Fresh interpretation study and complete evidence",
-      "url": "https://huggingface.co/datasets/vynly/nnai-bench/tree/main/fresh-transfer/2026-11-07-native"
-    }
+  "evidence": "Authored core development and regressions are separate from external benchmark results. This source snapshot claims no CellARC score or broad 0.4 win. See local CORE_0_4.md for scope.",
+  "sources": [],
+  "local_document_provenance": [
+    "CORE_0_4.md"
   ],
-  "local_document_provenance": [
-    "README.md",
-    "RELEASE_0_3_9_1.md",
-    "benchmarks/native_interpretation_0391/REPORT.md"
-  ],
-  "scope": "Curated installed product facts; never user messages or retrieved instructions."
+  "scope": "Curated isolated-candidate product facts, not user or benchmark answer data."
 }