File size: 48,560 Bytes
b33a97d | 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 | --- a/cael_unified/assistant.py
+++ b/cael_unified/assistant.py
@@ -94,6 +94,8 @@
def picture_follow_up(messages):
"""'make it blue' right after a picture: an edit of that picture, not a chat message."""
+ if len(messages) < 3 or not picture_reply(messages[-2]):
+ return False
from cael_image.scene import is_edit
text = messages[-1]['content']
return len(messages) >= 3 and picture_reply(messages[-2]) and is_edit(text) and not new_picture_request(text)
@@ -179,6 +181,18 @@
# 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
+ # 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)
+ and messages and messages[-1].get('role')=='user'
+ and all(isinstance(m,dict) and m.get('role') in ('system','user')
+ and isinstance(m.get('content'),str) for m in messages)
+ and sum(m['role']=='user' for m in messages)==1
+ and type(n) is int and 1<=n<=8
+ and (seed is None or type(seed) is int and 0<=seed<2**31)):
+ from .interpretation import reply as native_task_reply
+ task=messages[-1]['content'];framed=choice_problem(task)
+ answer=native_task_reply(framed[0],framed[1],self._reasoning) if framed else native_task_reply(task,reasoner=self._reasoning)
if answer is None and mode in ('auto','chat') and type(n) is int and 1<=n<=8 and (seed is None or type(seed) is int and 0<=seed<2**31):
from .dialogue import reply as dialogue_reply
answer=dialogue_reply(messages,lambda history:self._respond_request(history,n=n,seed=seed,mode=mode))
@@ -401,6 +415,10 @@
def _context_choice(self, context, endings):
"""The same existing solvers/counts, before an unrelated topic route."""
+ from .interpretation import reply as native_task_reply
+ native = native_task_reply(context, endings, self._reasoning)
+ if native is not None:
+ return dict(native, context_owned=True)
from .quantity_graph import choice_reply as quantity_choice
quantified=quantity_choice(context,endings)
if quantified is not None:
@@ -451,6 +469,13 @@
if not text:
raise ValueError('Request is empty')
if mode in ('auto', 'chat'):
+ # Native whole-task interpretation is shared by direct chat and MC;
+ # it never receives benchmark metadata, answer keys or model replies.
+ from .interpretation import reply as native_task_reply
+ framed = choice_problem(text)
+ native = native_task_reply(framed[0], framed[1], self._reasoning) if framed else native_task_reply(text, reasoner=self._reasoning)
+ if native is not None:
+ return native
def settled(found):
# A solver that cannot parse a clause asks for clarification; the everyday word-problem reasoner and
# the price/percent solver get a chance first (0.3.8 Q&A fix: "She gives 5 to Tom. Then she buys 8").
--- /dev/null
+++ b/cael_unified/interpretation.py
@@ -0,0 +1,54 @@
+"""Native typed interpretation -> bounded symbolic operations -> checked answer.
+
+This is part of Assistant inference, not an evaluator adapter. No benchmark IDs,
+gold answers, external models, network calls, weight changes or code execution.
+"""
+from fractions import Fraction
+from .interpretation_common import Result,TaskError,clean,display
+
+TRUTH={'true':'true','yes':'true','false':'false','no':'false','unknown':'unknown','undetermined':'unknown',
+ 'not enough information':'unknown','inconsistent':'conflicting','conflicting':'conflicting','both true and false':'conflicting'}
+
+def answer(result,options):
+ value=display(result.value)
+ proof={'family':result.family,'program':result.program,'value':value,'complete_source':True,
+ 'architecture':'bounded symbolic interpreter / relation and state automata','internet_used':False}
+ if options is not None:
+ matches=[]
+ for i,opt in enumerate(options):
+ if result.numeric:
+ try:equal=Fraction(opt.strip())==result.value
+ except (ValueError,ZeroDivisionError):equal=False
+ elif result.family=='logic':equal=TRUTH.get(clean(opt))==value
+ elif result.exact_text:equal=opt==value
+ else:equal=clean(opt)==clean(value)
+ if equal:matches.append(i)
+ if len(matches)!=1:raise TaskError('The checked result does not identify exactly one option; please clarify the answer choices.')
+ i=matches[0];response=f'{"ABCDEFGH"[i]}. {options[i]}'
+ else:
+ i=None;response=value if value else '""'
+ out={'route':'chat/native-'+result.family,'response':response,'sources':[],'reasoning':proof,
+ 'verification':{'status':'verified','check':'Complete typed source compiled to bounded '+result.family+' operations; all declared dependencies retained.'},
+ 'details':'Native non-neural interpretation of the full supplied task. '+str(result.program)}
+ if i is not None:out['choice']=i
+ return out
+
+def reply(text,options=None,reasoner=None):
+ if not isinstance(text,str) or not 0<len(text)<=40000:return None
+ if options is not None and (not 2<=len(options)<=8 or any(not isinstance(x,str) for x in options)):return None
+ from . import interpretation_data as data,interpretation_relations as relations,interpretation_arithmetic as arithmetic
+ cached=[reasoner]
+ def get_reasoner():
+ if cached[0] is None:
+ from .reasoning import Reasoner
+ cached[0]=Reasoner()
+ return cached[0]
+ for compile in (data.tables,data.strings,relations.order,lambda t:relations.state(t,get_reasoner),lambda t:relations.logic(t,get_reasoner),arithmetic.compile):
+ try:
+ result=compile(text)
+ if result is not None:return answer(result,options)
+ except TaskError as exc:
+ return {'route':'chat/native-task-clarify','response':'Please clarify: '+str(exc),'sources':[],
+ 'reasoning':{'complete_source':False,'unsupported_reason':str(exc),'internet_used':False},
+ 'details':'The complete task remains owned by its symbolic interpretation path; no unsupported clause is discarded for a statistical guess.'}
+ return None
--- /dev/null
+++ b/cael_unified/interpretation_arithmetic.py
@@ -0,0 +1,50 @@
+"""General arithmetic request boundary -> existing exact helper and checker."""
+from fractions import Fraction
+import re
+from .interpretation_common import Result,TaskError,boundary,strip_request,NUMBER
+from .helper import calculate
+from .verify import check_arithmetic,Unchecked
+
+# Function words describe the value/operation request, never extra operands or
+# conditions. Unknown content is not deleted to expose a convenient expression.
+WRAPPER=set('''what which number value result answer expression equation does do is are the a an this that it its of for from to in using with use obey follow usual standard ordinary integer exact arithmetic mathematical mathematics order operations parentheses have their meaning produce produces yield yields obtain obtained get evaluate evaluated calculate calculated compute computed work out find determine give select choose return tell me please resolve simplify amounts evaluates'''.split())
+
+def checked(expression):
+ answer=calculate('Calculate '+expression)
+ if not answer or answer.get('route')!='helper/arithmetic':raise TaskError('The complete expression is outside the bounded arithmetic interpreter.')
+ try:proof=check_arithmetic(expression,answer)
+ except (Unchecked,ValueError,ZeroDivisionError):raise TaskError('The arithmetic result could not be checked independently.')
+ if proof.get('status')!='verified':raise TaskError('The independent arithmetic check disagrees.')
+ return Fraction(answer['response'].split(' = ',1)[1].split(' (about',1)[0]),proof
+
+def compile(text):
+ t=strip_request(text)
+ # The established whole-expression reader retains percentages and supported
+ # word operators; no changes to its solver or protected kernels.
+ old=calculate(t)
+ if old and old.get('route')=='helper/arithmetic':
+ try:
+ p=check_arithmetic(t,old)
+ if p['status']=='verified':return Result(Fraction(old['response'].split(' = ',1)[1].split(' (about',1)[0]),'arithmetic',{'source_span':[0,len(text)],'operation':old['response'],'check':p},True)
+ except (Unchecked,ValueError,ZeroDivisionError):pass
+ intent=bool(re.search(r'(?i)\b(?:arithmetic|expression|calculate|compute|evaluate|work out|simplify|numeric value|number does|value of)\b',t) or re.match(r'(?i)(?:what|which|find|give|return|determine)\b.{0,35}\b(?:number|value|result)\b',t))
+ if not intent:return None
+ boundary(t)
+ # Convert lexical operators before finding a full numeric expression.
+ normalized=t
+ for phrase,op in [('multiplied by','*'),('divided by','/'),('times','*'),('plus','+'),('minus','-')]:
+ normalized=re.sub(r'(?i)\b'+phrase+r'\b',op,normalized)
+ normalized=normalized.replace('×','*').replace('÷','/').replace('−','-')
+ matches=list(re.finditer(r'(?<![\w])[+\-(.\s]*\d[\d\s()+*/%^.\-]*',normalized))
+ matches=[m for m in matches if re.search(r'\d',m[0])]
+ if not matches:
+ if re.search(r'__|\b(?:import|exec|eval|open)\s*\(',t):raise TaskError('Only bounded literal arithmetic is accepted; code and imports are not arithmetic values.')
+ return None
+ if len(matches)!=1:raise TaskError('Several numeric fragments need an explicit supported operation connecting them.')
+ m=matches[0];expression=m[0].strip().rstrip(' .?=')
+ wrapper=normalized[:m.start()]+' '+normalized[m.end():]
+ words=re.findall(r'[A-Za-z]+',wrapper.casefold())
+ if any(w not in WRAPPER for w in words) or re.search(r'[^A-Za-z\s,.:?!=\'"-]',wrapper):
+ raise TaskError('The expression has an additional clause or qualifier outside the checked arithmetic request grammar.')
+ value,proof=checked(expression)
+ return Result(value,'arithmetic',{'source_span':[0,len(text)],'expression':expression,'checked_wrapper_words':words,'check':proof},True)
--- /dev/null
+++ b/cael_unified/interpretation_common.py
@@ -0,0 +1,77 @@
+"""Shared bounded symbols/clauses for native symbolic task compilation."""
+from dataclasses import dataclass
+from fractions import Fraction
+import re
+
+class TaskError(ValueError):
+ pass
+
+ATOM=r'[A-Za-z][A-Za-z0-9_-]*(?: [A-Za-z][A-Za-z0-9_-]*){0,7}'
+NUMBER=r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:/[+-]?\d+)?'
+
+def clean(s):
+ s=s.strip().strip('"\'`').strip().rstrip(' .?!')
+ return re.sub(r'\s+',' ',s).casefold()
+
+def symbol(s):
+ s=re.sub(r'(?i)^(?:the|an?)\s+','',s.strip())
+ if not re.fullmatch(ATOM,s) or re.search(r'(?i)\b(?:unless|except|maybe|perhaps|sometimes|either|or|if|then|and)\b',s):
+ raise TaskError('An entity or property has an ambiguous or unsupported modifier.')
+ return clean(s)
+
+def alphabet(n):
+ out=''
+ while True:
+ out=chr(97+n%26)+out;n=n//26-1
+ if n<0:return out
+
+def clauses(text):
+ """Keep decimal points and quoted text intact; every clause remains visible."""
+ out=[];start=0;quote=None
+ for i,ch in enumerate(text):
+ if ch in ('"',"'") and (i==0 or text[i-1]!='\\'):
+ # Apostrophes inside words are not quotation delimiters.
+ if ch=="'" and i and i+1<len(text) and text[i-1].isalnum() and text[i+1].isalnum():continue
+ if quote==ch:quote=None
+ elif quote is None:quote=ch
+ stop=ch in '\n;' or (ch=='.' and not (i and i+1<len(text) and text[i-1].isdigit() and text[i+1].isdigit()))
+ if stop and quote is None:
+ if text[start:i].strip():out.append(text[start:i].strip())
+ start=i+1
+ if text[start:].strip():out.append(text[start:].strip())
+ return out
+
+def spans(text,parts):
+ result=[];offset=0
+ for part in parts:
+ i=text.find(part,offset)
+ if i<0:raise TaskError('Source coverage could not be established.')
+ result.append({'text':part,'span':[i,i+len(part)]});offset=i+len(part)
+ return result
+
+def fraction(s):
+ s=s.strip()
+ if not re.fullmatch(NUMBER,s):raise TaskError('A numeric cell is missing or is not an explicit rational number.')
+ try:v=Fraction(s)
+ except (ValueError,ZeroDivisionError):raise TaskError('A number or denominator is invalid.')
+ if max(v.numerator.bit_length(),v.denominator.bit_length())>512:raise TaskError('The numeric value is outside the checked size limit.')
+ return v
+
+def display(v):
+ if isinstance(v,Fraction):return str(v.numerator) if v.denominator==1 else str(v)
+ return str(v)
+
+def boundary(text):
+ if re.search(r'(?i)\b(?:unless|except|assuming|approximately|probably|maybe|possibly|ignore|disregard|pretend)\b|\bround(?:ed|ing)?\s+(?:to|off|up|down|it|the result)\b',text):
+ raise TaskError('A condition, exception or approximation needs a supported explicit interpretation; it cannot be omitted.')
+
+def strip_request(text):
+ return re.sub(r'(?i)^\s*(?:(?:can|could|would) you\s+)?(?:please\s+)?','',text).strip()
+
+@dataclass
+class Result:
+ value: object
+ family: str
+ program: dict
+ numeric: bool=False
+ exact_text: bool=False
--- /dev/null
+++ b/cael_unified/interpretation_data.py
@@ -0,0 +1,201 @@
+"""Bounded string-operation pipelines and typed table reductions, from source."""
+import csv
+from fractions import Fraction
+import json
+import re
+from .interpretation_common import Result,TaskError,boundary,clauses,clean,fraction,spans,strip_request
+
+def strings(text):
+ t=strip_request(text)
+ if not re.search(r'(?i)\b(?:reverse|backwards|backward|rotate|characters|substring|uppercase|lowercase|swapcase|append|prepend|replace)\b',t):return None
+ if re.match(r'(?i)(?:write|create|implement|define|build|explain) (?:a |the )?(?:python |javascript )?(?:function|script|program|code)\b',t):return None
+ literals=[]
+ def mask(m):
+ raw=m[0]
+ try:value=json.loads(raw) if raw.startswith('"') else raw[1:-1]
+ except ValueError:raise TaskError('A quoted string literal has invalid escaping.')
+ literals.append(value);return f'literalq{len(literals)-1}'
+ source=re.sub(r'''"(?:[^"\\]|\\.)*"|'[^']*' ''',mask,t,flags=re.X)
+ if not literals:return None
+ if not re.search(r'(?i)\b(?:reverse|backwards|backward|rotate|characters|substring|uppercase|lowercase|swapcase|append|prepend|replace)\b',source):return None
+ if not re.match(r'(?i)^(?:from\b|for\b|given\b|using\b|with\b|text\s*:|string\s*:|input\s*:|reverse\b|rotate\b|keep\b|retain\b|select\b|take\b|read\b|write\b|move\b|transfer\b|drop\b|remove\b|delete\b|convert\b|change\b|make\b|uppercase\b|lowercase\b|swapcase\b|append\b|prepend\b|replace\b|number\b|index\b)',source):return None
+ if len(literals[0])>2048 or len(literals)>8:raise TaskError('Use at most eight quoted literals and an input string within 2,048 characters.')
+ boundary(source)
+ source=re.sub(r'(?i)^(?:for|given|using|with)(?: the)? (?:text|string|sequence|characters)\s*(?:is|:)?\s*literalq0\s*[,.:]?\s*','',source)
+ source=re.sub(r'(?i)^(?:text|string|input)\s*:\s*literalq0\s*[,.:]?\s*','',source)
+ source=re.sub(r'(?i)\b(?:the )?(?:string|text) (literalq\d+)\b',r'\1',source)
+ pieces=[]
+ for p in clauses(source):pieces.extend(re.split(r'(?i)\s*,?\s*\b(?:then|after that|next)\b\s*',p))
+ pieces=[p.strip(' ,.:?') for p in pieces if p.strip(' ,.:?')]
+ value=literals[0];ops=[];used={0};base=1
+ arg=r'(?:literalq0|(?:the |its )?(?:entire |resulting |current |original )?(?:string|text|characters|letters|result|sequence)|it)'
+ reverse_words=set('reverse read write take the its all entire resulting current original string text characters letters result sequence it literalq0 in of reverse order backwards backward from last one to first what which is results produces do you obtain choose final'.split())
+ for part in pieces:
+ p=part.casefold()
+ m=re.fullmatch(rf'(?:number|index) (?:the )?characters (?:of |in )?{arg} (?:starting|beginning) (?:at|from) ([01])',p)
+ if m:base=int(m[1]);continue
+ m=re.fullmatch(r'(?:use|using) ([01])[- ]based (?:positions|indexing|indices)',p)
+ if m:base=int(m[1]);continue
+ # Query-only clauses carry no additional transformation.
+ if re.fullmatch(r'(?:what|which)(?: is| do you get| do you obtain)?(?: the)?(?: final| resulting)? (?:string|text|sequence|result)(?: results| remains| do you get| do you obtain)?',p) or p in ('choose the final string','select the resulting string','what remains','which sequence results'):continue
+ op=None
+ m=re.fullmatch(rf'rotate\s+{arg}?\s*(left|right)\s*(?:by )?(\d+)\s*(?:characters?|positions?|places?)?',p)
+ if m:op=('rotate',m[1],int(m[2]))
+ if op is None:
+ m=re.fullmatch(r'(?:move|transfer) (?:the )?(first|last) (\d+) characters (?:of literalq0 )?to (?:its |the )?(end|front|beginning)(?: without (?:reordering|changing their order))?',p)
+ if m and ((m[1]=='first' and m[3]=='end') or (m[1]=='last' and m[3] in ('front','beginning'))):op=('rotate','left' if m[1]=='first' else 'right',int(m[2]))
+ if op is None:
+ m=re.fullmatch(rf'(?:keep|retain|select|take)(?: only)? (?:the )?(even|odd)[- ](?:numbered|positioned) characters(?: (?:of|from|in) {arg})?(?:,? (?:without changing|preserving) their order)?',p)
+ if m:op=('parity',0 if m[1]=='even' else 1,base)
+ if op is None:
+ m=re.fullmatch(rf'(?:from {arg},? )?(?:keep|retain|select|take)(?: only)? (?:the )?characters (?:at|in) positions? ([\d, ]+)(?:,? and so on)?(?: \(([01])[- ]based\))?',p)
+ if m:
+ positions=[int(x) for x in re.findall(r'\d+',m[1])];indexbase=int(m[2]) if m[2] else base
+ if 'and so on' in p:
+ if len(positions)<2 or len(set(b-a for a,b in zip(positions,positions[1:])))!=1 or positions[1]<=positions[0]:raise TaskError('The repeated position pattern is not uniquely specified.')
+ op=('positions_progression',positions[0]-indexbase,positions[1]-positions[0])
+ else:op=('positions',[i-indexbase for i in positions])
+ if op is None:
+ m=re.fullmatch(rf'(?:remove|drop|delete) (?:the )?(first|last) (\d+) characters(?: (?:of|from) {arg})?',p)
+ if m:op=('drop',m[1],int(m[2]))
+ if op is None:
+ m=re.fullmatch(rf'(?:convert|change|make) {arg} (?:to |all )?(upper(?:case)?|lower(?:case)?)(?: letters)?',p) or re.fullmatch(rf'(uppercase|lowercase|swapcase) {arg}',p)
+ if m:op=('case','upper' if m[1].startswith('upper') else 'lower' if m[1].startswith('lower') else 'swapcase')
+ if op is None:
+ m=re.fullmatch(r'(append|prepend) literalq(\d+)(?: to (?:the )?(?:result|string|text|it))?',p)
+ if m:op=(m[1],int(m[2]));used.add(int(m[2]))
+ if op is None:
+ m=re.fullmatch(r'replace (?:every |each |all )?(?:occurrence of )?literalq(\d+) (?:with|by) literalq(\d+)(?: in (?:the )?(?:result|string|text|it))?',p)
+ if m:op=('replace',int(m[1]),int(m[2]));used.update((int(m[1]),int(m[2])))
+ if op is None:
+ words=set(re.findall(r'[a-z0-9]+',p))
+ if (re.search(r'\b(?:reverse|backwards|backward)\b',p) or ('last' in words and 'first' in words and words&{'read','write','take'})) and words<=reverse_words:op=('reverse',)
+ if op is None:raise TaskError('A string operation or indexing instruction is unsupported: '+part[:100])
+ before=value
+ if op[0]=='reverse':value=''.join(reversed(value))
+ elif op[0]=='rotate':
+ k=op[2]%len(value) if value else 0
+ if op[1]=='right':k=(-k)%len(value) if value else 0
+ value=value[k:]+value[:k]
+ elif op[0]=='parity':value=''.join(ch for i,ch in enumerate(value) if (i+op[2])%2==op[1])
+ elif op[0]=='positions':
+ if any(i<0 or i>=len(value) for i in op[1]):raise TaskError('An explicit requested character position is out of bounds.')
+ value=''.join(value[i] for i in op[1])
+ elif op[0]=='positions_progression':
+ if op[1]<0:raise TaskError('A requested position precedes the declared index origin.')
+ value=''.join(value[i] for i in range(op[1],len(value),op[2]))
+ elif op[0]=='drop':value=value[op[2]:] if op[1]=='first' else value[:max(0,len(value)-op[2])]
+ elif op[0]=='case':value=getattr(value,op[1])()
+ elif op[0]=='append':value+=literals[op[1]]
+ elif op[0]=='prepend':value=literals[op[1]]+value
+ else:
+ if not literals[op[1]]:raise TaskError('Replacing an empty substring needs an explicit insertion convention.')
+ value=value.replace(literals[op[1]],literals[op[2]])
+ if len(value)>16384 or len(ops)>=12:raise TaskError('The string program exceeds its checked size bound.')
+ ops.append({'source':part,'operation':op,'before':before,'after':value})
+ if not ops:return None
+ if used!=set(range(len(literals))):raise TaskError('A quoted value was not consumed by the complete string specification.')
+ return Result(value,'strings',{'source_span':[0,len(text)],'literals':literals,'operations':ops,'index_origin':base},exact_text=True)
+
+ALIASES=[{'quantity','qty','units','unit count','amount'}, {'price','unit price','cost','unit cost'},
+ {'group','category','class','type'}, {'name','item','product','entry','identifier','id'}]
+
+def tables(text):
+ lines=[s.strip() for s in text.splitlines() if s.strip()]
+ if len(lines)<3:return None
+ header=lines[0]
+ delimiter='|' if '|' in header else '\t' if '\t' in header else ',' if ',' in header else None
+ if delimiter is None:return None
+ header=re.sub(r'(?i)^(?:table|records|data)\s*:\s*','',header)
+ def cells(line):
+ line=line.strip().strip('|') if delimiter=='|' else line
+ return [x.strip() for x in next(csv.reader([line],delimiter=delimiter))]
+ headers=cells(header);keys=[re.sub(r'[_-]+',' ',clean(x)) for x in headers]
+ if not 2<=len(keys)<=16 or len(set(keys))!=len(keys) or any(not re.fullmatch(r'[a-z][a-z0-9 ]{0,60}',k) for k in keys):return None
+ records=[];qlines=[];data_lines=[]
+ for line in lines[1:]:
+ row=cells(line)
+ if len(row)==len(keys) and all(re.fullmatch(r':?-+:?',x) for x in row):data_lines.append(line);continue
+ if delimiter in line and not qlines:
+ if len(row)!=len(keys):raise TaskError('A table record has a different number of cells from its header.')
+ if any(x=='' for x in row):raise TaskError('A table contains an empty cell; its meaning must be specified.')
+ records.append(row);data_lines.append(line)
+ else:qlines.append(line)
+ if not records or not qlines:return None
+ if len(records)>200:raise TaskError('Use at most 200 explicit table records.')
+ query=' '.join(qlines).strip();boundary(query)
+ if re.search(r'(?i)\b(?:or|either|unless|except)\b',query):raise TaskError('This table query has an unsupported condition or combination.')
+ aliases={}
+ for i,k in enumerate(keys):
+ aliases.setdefault(k,set()).add(i)
+ for group in ALIASES:
+ if k in group:
+ for term in group:aliases.setdefault(term,set()).add(i)
+ def field(label):
+ label=re.sub(r'[_-]+',' ',clean(label))
+ if label in keys:return keys.index(label)
+ found=aliases.get(label,set())
+ if len(found)!=1:raise TaskError('A queried table column is missing or has an ambiguous alias: '+label)
+ return next(iter(found))
+ # "product of X and Y" is an operation, not an inferred name-column alias.
+ if 'product' not in keys:aliases.pop('product',None)
+ all_fields='|'.join(re.escape(k).replace(r'\ ',r'[ _-]+') for k in sorted(aliases,key=len,reverse=True))
+ filters=[]
+ # Conditions use only actual declared fields/aliases. No inferred row labels.
+ condition=rf'(?P<field>{all_fields})\s*(?P<op>is not|is|equals|equal to|!=|>=|<=|=|>|<)?\s+(?P<value>"[^"]*"|\x27[^\x27]*\x27|[A-Za-z0-9_+./-]+)'
+ region=re.search(rf'(?i)\b(?:for|with|where|whose|having|that have|have|has)\s+(?:the )?(?:records?\s+(?:where|with)\s+)?{condition}',query)
+ masked=query
+ if region:
+ idx=field(region['field']);op=(region['op'] or '=').casefold();val=region['value'].strip('"\'') if region['value'].startswith(('"',"'")) else region['value'].rstrip('.?')
+ filters.append((idx,op,val));masked=query[:region.start()]+' FILTER '+query[region.end():]
+ # Additional conjunction conditions must be completely covered too.
+ after_filter=region.start()+len(' FILTER ')
+ while True:
+ m=re.match(rf'(?i)\s*and\s+{condition}',masked[after_filter:])
+ if not m:break
+ val=m['value'].strip('"\'') if m['value'].startswith(('"',"'")) else m['value'].rstrip('.?')
+ filters.append((field(m['field']),(m['op'] or '=').casefold(),val))
+ masked=masked[:after_filter]+' FILTER '+masked[after_filter+m.end():]
+ after_filter+=len(' FILTER ')
+ if re.search(r'(?i)\b(?:where|whose|having)\b',masked):raise TaskError('A table filter could not be fully parsed.')
+ selected=[]
+ for ri,row in enumerate(records):
+ keep=True
+ for col,op,value in filters:
+ actual=row[col]
+ if op in ('>','<','>=','<='):
+ a,b=fraction(actual),fraction(value);accept={'>':a>b,'<':a<b,'>=':a>=b,'<=':a<=b}[op]
+ else:
+ try:a,b=fraction(actual),fraction(value)
+ except TaskError:a,b=actual,value
+ accept=(a!=b) if op in ('is not','!=') else (a==b)
+ keep &= accept
+ if keep:selected.append(ri)
+ # Replace column references with typed variables before lexical coverage.
+ referenced=[]
+ def replace(m):
+ i=field(m[0]);referenced.append(i);return ' FIELD '
+ residual=re.sub(rf'(?i)(?<![\w])(?:{all_fields})(?![\w])',replace,masked)
+ words=set(re.findall(r'[A-Za-z]+',residual.casefold()))
+ vocabulary=set('''filter field what which is are the a an of for with where whose having in per how many number count rows row records record entries entry selected matching all each every their corresponding values value total sum add added adding aggregate combined quantity amounts multiply multiplied by times product products take calculate compute find determine give return choose select obtain get do you result answer these those this that and then to on its together table data please'''.split())
+ vocabulary.update(('minimum','maximum','min','max','average','mean','smallest','largest'))
+ if not words<=vocabulary or re.search(r'[^A-Za-z\s\d,.:?*+/_()=-]',residual):raise TaskError('The complete table query contains an uncompiled operation or qualifier.')
+ q=residual.casefold();fields=list(dict.fromkeys(referenced))
+ if re.search(r'\b(?:count|how many|number of (?:rows|records|entries))\b',q):
+ if fields:raise TaskError('Specify whether to count records or aggregate a column.')
+ value=Fraction(len(selected));operation='count'
+ else:
+ reduce='mean' if re.search(r'\b(?:mean|average)\b',q) else 'min' if re.search(r'\b(?:minimum|min|smallest)\b',q) else 'max' if re.search(r'\b(?:maximum|max|largest)\b',q) else 'sum' if re.search(r'\b(?:sum|total|add|adding|aggregate|combined)\b',q) else None
+ if reduce is None or not fields:raise TaskError('Specify a supported reducer and its numeric column.')
+ product=bool(re.search(r'\b(?:multiply|multiplied|times|product|products)\b|\*',q))
+ if len(fields)!= (2 if product else 1):raise TaskError('The numeric column expression is missing or ambiguous.')
+ values=[]
+ for ri in selected:
+ v=fraction(records[ri][fields[0]])
+ if product:v*=fraction(records[ri][fields[1]])
+ values.append(v)
+ if reduce!='sum' and not values:raise TaskError('The selected table is empty, so this reducer is undefined.')
+ value=sum(values,Fraction(0)) if reduce=='sum' else sum(values,Fraction(0))/len(values) if reduce=='mean' else min(values) if reduce=='min' else max(values)
+ operation=reduce+('_product' if product else '')
+ return Result(value,'tables',{'coverage':spans(text,lines),'headers':headers,'records':records,'filters':filters,
+ 'selected_record_indices':selected,'operation':operation,'numeric_columns':fields,'value':str(value)},True)
--- /dev/null
+++ b/cael_unified/interpretation_relations.py
@@ -0,0 +1,246 @@
+"""Source-covered rule, precedence and chronological event compilation.
+
+Symbolic relations and finite state only. No learned answer table or neural path.
+"""
+import re
+from .interpretation_common import ATOM,Result,TaskError,alphabet,boundary,clauses,clean,spans,symbol
+
+def relation_atom(s):
+ s=re.sub(r'(?i)^(?:(?:the|an?) )?(?:(?:class|category|type|group)\s+)?','',s.strip())
+ return symbol(s)
+
+def membership(text,question=False):
+ t=text.strip().strip('"\'').rstrip(' ?.');negative=False
+ if question and re.match(r'(?i)^is ',t):
+ t=t[3:];m=re.fullmatch(rf'({ATOM}?) (not )?(?:an? |the |in (?:the )?(?:class |category |group )?|of type )(.+)',t,re.I)
+ if m:return symbol(m[1]),relation_atom(m[3]),bool(m[2])
+ m=re.fullmatch(rf'({ATOM}?) (does not belong to|is not a member of|is a member of|is classified as|has type|belongs to|is not|is) (.+)',t,re.I)
+ if not m:return None
+ return symbol(m[1]),relation_atom(m[3]),m[2].casefold() in ('is not','does not belong to','is not a member of')
+
+def _logic_query(q):
+ q=q.strip().rstrip(' ?.');q=re.sub(r'(?i)^(?:question|query)\s*:\s*','',q)
+ q=re.sub(r'(?i)^(?:(?:under|using) open[- ]world (?:logic|assumptions),?\s*)','',q)
+ quoted=re.search(r'''(["'])(.+?)\1''',q)
+ if quoted:
+ tail=q[quoted.end():]
+ if not re.fullmatch(r'(?i)\s*(?:(?:as )?(?:true|false|unknown|undetermined|inconsistent|or|and|,|/|\s)+)?',tail):return None
+ if not re.fullmatch(r'(?i)(?:is|classify|evaluate|judge)(?: the (?:claim|statement|proposition))?\s*',q[:quoted.start()]):return None
+ return membership(quoted[2])
+ q=re.sub(r'(?i)^(?:classify|judge|evaluate|determine)(?: the)? (?:claim|statement|proposition)(?: that)?\s*','',q)
+ q=re.sub(r'(?i)^(?:does (?:it|the information|the evidence) (?:follow|establish)(?: that)?|can (?:we|you) conclude(?: that)?|is it true that|prove(?: that)?|what follows about)\s*','',q)
+ # A truth-value legend must have its complete standard meanings. It is
+ # metadata, not additional facts about the entities.
+ if ':' in q:
+ q,legend=q.split(':',1)
+ words=set(re.findall(r'[a-z]+',legend.casefold()))
+ allowed=set('true false unknown undetermined inconsistent conflicting means proved proven negation its is neither both and or not established supported contradicted the statement claim'.split())
+ if not words<=allowed:raise TaskError('The answer legend contains an unsupported logical convention.')
+ if not {'true','false'}<=words:raise TaskError('State the complete logical answer convention.')
+ standard=re.fullmatch(r'(?i)\s*true means (?:proved|proven|supported),? false means (?:its|the) negation is (?:proved|proven|supported),? unknown means neither,? and (?:inconsistent|conflicting) means both\s*',legend)
+ if not standard:raise TaskError('The truth-value legend must preserve the standard proved/negated/neither/both meanings.')
+ return membership(q,True) or membership(q)
+
+def logic(text,reasoner):
+ parts=clauses(text)
+ if len(parts)<2:return None
+ if not re.search(r'(?i)\b(?:every|all|each|anything|everything|implies|open[- ]world|premises|facts|rules)\b|(?:^|\n)(?:query|question)\s*:|true,? false',text):return None
+ if not re.match(r'(?i)^(?:is\b|under\b|using\b|classify\b|judge\b|evaluate\b|determine\b|question\s*:|query\s*:|does\b|can\b|prove\b|what follows about\b)',parts[-1]):return None
+ if any(re.search(r'(?i)\band\b',p) for p in parts[:-1]) and not re.search(r'(?i)\b(?:class|category|belongs|type)\b',text):return None
+ q=_logic_query(parts[-1])
+ if q is None:return None
+ # A local scenario, not an unsupported attempt to answer general knowledge.
+ if not any(re.search(r'(?i)\b(?:is|has|every|all|each|anything|belongs|implies|classified)\b',p) for p in parts[:-1]):return None
+ facts=[];rules=[];used=[]
+ for raw in parts[:-1]:
+ p=re.sub(r'(?i)^(?:facts|premises|given|rules|scenario|context)\s*:\s*','',raw).strip()
+ if not p:used.append(raw);continue
+ if re.fullmatch(r'(?i)(?:use|consider) only (?:these|the stated|the supplied) (?:facts|statements|premises)(?:,? (?:a )?missing (?:fact|statement) is not false,? and (?:an )?implication cannot be reversed)?',p):used.append(raw);continue
+ if re.fullmatch(r'(?i)(?:no fact implies its converse|use open[- ]world (?:logic|reasoning)|absence (?:of a fact )?(?:is|means) (?:not falsity|unknown))',p):used.append(raw);continue
+ if re.fullmatch(r'(?i)(?:an? )?(?:missing|unstated|absent) (?:fact|statement) (?:is not false|is unknown|does not imply (?:its )?negation)(?:,? and (?:an? )?implication cannot be reversed)?',p):used.append(raw);continue
+ boundary(p)
+ m=re.fullmatch(r'(?i)(?:anything|everything|any entity|any object) (?:in (?:the )?(?:class|category|group)|that is|of type|with type) (.+?) (?:also belongs to|belongs to|is also|is) (.+)',p)
+ if not m:m=re.fullmatch(r'(?i)(?:all|every|each|any) (.+?) (?:are also|is also|are|is|belong to|belongs to|must be) (.+)',p)
+ if not m:m=re.fullmatch(r'(?i)if (?:something|an? (?:object|entity|thing)) (?:is|belongs to) (.+?)(?:,)? then (?:it|that entity|that object) (?:is|belongs to) (.+)',p)
+ if not m:m=re.fullmatch(r'(?i)(.+?) implies (.+)',p)
+ if m:
+ from .reasoning_logic import LogicCA
+ grammar=LogicCA(reasoner().model)
+ source=grammar.noun(relation_atom(m[1]),plural=bool(re.match(r'(?i)^all\b',p)))
+ target=m[2].strip();neg=bool(re.match(r'(?i)^not ',target))
+ target=grammar.noun(relation_atom(re.sub(r'(?i)^not ','',target)),plural=bool(re.search(r'(?i)\bare(?: also)?\b',p)))
+ rules.append((source,target,neg));used.append(raw);continue
+ f=membership(p)
+ if f is None:raise TaskError('A local logical premise could not be compiled in full: '+p[:100])
+ facts.append(f);used.append(raw)
+ if not facts or len(facts)>128 or len(rules)>128:return None
+ mapping={}
+ def bound(kind,v):
+ key=(kind,v)
+ if key not in mapping:mapping[key]='native'+alphabet(len(mapping))+'q'
+ return mapping[key]
+ canonical=[f'{bound("entity",e)} is {"not " if neg else ""}{bound("property",p)}.' for e,p,neg in facts]
+ canonical += [f'Every {bound("property",a)} is {"not " if neg else ""}{bound("property",b)}.' for a,b,neg in rules]
+ query=f'{bound("entity",q[0])} is {"not " if q[2] else ""}{bound("property",q[1])}'
+ from .reasoning_logic import LogicCA,Unparsed
+ try:answer=LogicCA(reasoner().model).query(' '.join(canonical),query)
+ except Unparsed as exc:raise TaskError(str(exc)) from exc
+ return Result(answer['answer'],'logic',{'coverage':spans(text,parts),'facts':facts,'rules':rules,'query':q,
+ 'canonical_premises':canonical,'canonical_query':query,'proof':answer,'symbols':{f'{k[0]}:{k[1]}':v for k,v in mapping.items()}})
+
+def _order_query(q):
+ q=re.sub(r'(?i)^(?:question|query)\s*:\s*','',q.strip()).rstrip(' ?.').casefold()
+ m=re.search(r'\b(?:immediately|directly|right) (after|before|following|preceding) (.+?)(?: in (?:that|the) (?:sequence|order))?$',q)
+ if m:return ('after' if m[1] in ('after','following') else 'before',symbol(m[2]))
+ m=re.fullmatch(r'(?:name|give|identify|find|select|choose|which|what)(?: the| item| object| entry| is| comes| one| earliest| final)* (?:item )?(?:comes |is )?(first|last|earliest|latest|final)(?: item| one)?(?: in (?:that|the) (?:sequence|order))?',q)
+ if m:return ('first' if m[1] in ('first','earliest') else 'last',None)
+ if re.search(r'\b(?:earliest|first|final|last|latest) (?:item|object|entry)\b',q) and re.match(r'(?:name|give|identify|find|select|choose|which|what)\b',q):return ('unsupported',None)
+ m=re.fullmatch(r'(?:which|what) (?:item|object|entry) (?:comes|is) (?:in )?(?:position )?(\d+)(?:st|nd|rd|th)?',q)
+ if m:return ('index',int(m[1])-1)
+ m=re.fullmatch(r'(?:give|what is|find)(?: the)? (?:complete )?(?:order|sequence)',q)
+ if m:return ('all',None)
+ return None
+
+def order(text):
+ parts=clauses(text)
+ if len(parts)<2:return None
+ query=_order_query(parts[-1])
+ if query is None:return None
+ 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.')
+ names=[];edges=[]
+ for raw in parts[:-1]:
+ p=raw.strip()
+ listed=re.fullmatch(r'(?i)(?:items|objects|entries|participants)\s*:\s*(.+)',p)
+ if listed:
+ names.extend(symbol(x) for x in re.split(r'\s*,\s*|\s+and\s+',listed[1]));continue
+ if re.fullmatch(r'(?i)(?:all|each) (?:items|objects|entries) (?:occur|occurs|appear|appears) once',p):continue
+ if re.fullmatch(r'(?i)(?:the following|these) (?:constraints|statements|clues) describe (?:their|the) order',p):continue
+ boundary(p)
+ p=re.sub(r'(?i) in the (?:sequence|order)$','',p)
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:comes |is |occurs |appears )?(before|after|ahead of|behind|precedes|follows) ({ATOM})(?: in the (?:sequence|order))?',p)
+ if not m:raise TaskError('An ordering constraint is unsupported or incomplete: '+p[:100])
+ a,b=symbol(m[1]),symbol(m[3]);relation=m[2].casefold()
+ if relation in ('after','behind','follows'):a,b=b,a
+ names.extend((a,b));edges.append((a,b))
+ names=list(dict.fromkeys(names))
+ if not 2<=len(names)<=64 or len(edges)>512:raise TaskError('Use 2–64 distinct items and at most 512 precedence constraints.')
+ reach={a:{b for x,b in edges if x==a} for a in names}
+ # Monotone relation-cell fixed point, not factorial permutation search.
+ for _ in range(len(names)):
+ following={a:reach[a]|set().union(*(reach[b] for b in reach[a])) if reach[a] else set() for a in names}
+ if following==reach:break
+ reach=following
+ if any(a in reach[a] for a in names):raise TaskError('The stated order contains a cycle; no linear order satisfies it.')
+ predecessors={b:{a for a in names if b in reach[a]} for b in names}
+ op,arg=query;possible=[]
+ if op=='first':possible=[a for a in names if not predecessors[a]]
+ elif op=='last':possible=[a for a in names if not reach[a]]
+ elif op=='index':
+ if not 0<=arg<len(names):raise TaskError('The requested position is outside the stated order.')
+ possible=[a for a in names if len(predecessors[a])<=arg<=len(names)-1-len(reach[a])]
+ elif op in ('after','before'):
+ if arg not in names:raise TaskError('The queried item is absent from the constraints.')
+ rel=reach if op=='after' else predecessors
+ # Adjacent candidates are precisely incomparable nodes or cover edges;
+ # if the anchor may be last/first, no next/previous item is guaranteed.
+ if not rel[arg]:possible=[]
+ else:possible=[b for b in names if b!=arg and arg not in rel[b] and not any(x in rel[arg] and b in rel[x] for x in names if x not in (arg,b))]
+ else:
+ if any(not (a in reach[b] or b in reach[a]) for i,a in enumerate(names) for b in names[i+1:]):possible=[]
+ else:possible=[', '.join(sorted(names,key=lambda a:len(predecessors[a])))]
+ value=possible[0] if len(possible)==1 else 'unknown'
+ return Result(value,'ordering',{'coverage':spans(text,parts),'entities':names,'edges':edges,'query':query,
+ 'transitive_relation':{a:sorted(v) for a,v in reach.items()},'possible_answers':possible,'unique':len(possible)==1})
+
+def _state_query(q):
+ q=re.sub(r'(?i)^(?:question|query)\s*:\s*','',q.strip()).rstrip(' ?.').casefold()
+ q=re.sub(r'^(?:after (?:the )?(?:last|final) (?:entry|event|update),?\s*)','',q)
+ m=re.fullmatch(r'(?:where (?:is|was)|(?:give|name|find|state|report|what is)(?: the)? (?:current|latest|final|present|last known) (?:location|position|room) (?:of|for)) (.+?)(?: now| currently| at the end)?',q)
+ if m:return ('location',symbol(m[1]))
+ m=re.fullmatch(r'who (?:has|holds|owns|is carrying) (?:the )?(.+)',q)
+ if m:return ('owner',symbol(m[1]))
+ return None
+
+def state(text,reasoner):
+ parts=clauses(text)
+ if len(parts)<2:return None
+ query=_state_query(parts[-1])
+ if query is None:return None
+ if not any(re.search(r'(?i)\b(?:went|moved|relocated|entered|arrived|walked|travelled|traveled|journeyed|is in|is at|holds|picked up|gave|gives|dropped|location becomes)\b',p) for p in parts[:-1]):return None
+ events=[];reverse=False
+ header_words=set('read this the following movement location log events event entries entry are is listed recorded ordered from earliest latest oldest newest to top bottom each replaces that person s previous position in chronological order timeline record updates update given all information complete'.split())
+ for raw in parts[:-1]:
+ p=re.sub(r'(?i)^(?:facts|events|log|timeline|context)\s*:\s*','',raw).strip()
+ if not p:continue
+ if re.fullmatch(r'(?i)(?:events|entries|updates)(?: are)? (?:listed |recorded )?(?:in )?(?:reverse chronological order|latest first|newest first)',p):reverse=True;continue
+ 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}?) (?: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)
+ if m:events.append(('move',symbol(m[1]),symbol(m[2])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:is|was|is currently|was initially)(?: located)? (?:in|at|inside) (?:the )?({ATOM})',p)
+ if m:events.append(('move',symbol(m[1]),symbol(m[2])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:arrived|arrives) at (?:the )?({ATOM})',p)
+ if m:events.append(('move',symbol(m[1]),symbol(m[2])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) moved from (?:the )?({ATOM}?) to (?:the )?({ATOM})',p)
+ if m:events.append(('from',symbol(m[1]),symbol(m[2]),symbol(m[3])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:holds|has|got|picked up|takes|took) (?:the )?({ATOM})',p)
+ if m:events.append(('take',symbol(m[1]),symbol(m[2])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:gave|gives|handed|hands|passed) (?:the )?({ATOM}?) to ({ATOM})',p)
+ if m:events.append(('give',symbol(m[1]),symbol(m[2]),symbol(m[3])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?) (?:dropped|drops|put down) (?:the )?({ATOM})',p)
+ if m:events.append(('drop',symbol(m[1]),symbol(m[2])));continue
+ m=re.fullmatch(rf'(?i)({ATOM}?)\x27s location becomes ({ATOM}?)\x27s current location',p)
+ if m:events.append(('copy',symbol(m[1]),symbol(m[2])));continue
+ raise TaskError('A chronological event could not be compiled in full: '+p[:100])
+ if not events or len(events)>128:raise TaskError('Use a nonempty log of at most 128 explicit events.')
+ if reverse:events.reverse()
+ mapping={};back={}
+ def name(v):
+ if v not in mapping:
+ mapped='native'+alphabet(len(mapping))+'q';mapping[v]=mapped;back[mapped]=v
+ return mapping[v]
+ from .reasoning_world import WorldCA
+ world=WorldCA(reasoner().model);canonical=[];known_actors=set();known_objects=set()
+ for index,event in enumerate(events,1):
+ op,actor,value,*extra=event
+ if actor in ('he','she','they','it'):
+ if len(known_actors)!=1:raise TaskError('A pronoun has more than one possible actor; name the person explicitly.')
+ actor=next(iter(known_actors))
+ known_actors.add(actor)
+ if op in ('take','drop','give'):
+ if value=='it':
+ if len(known_objects)!=1:raise TaskError('The object pronoun has no unique referent.')
+ value=next(iter(known_objects))
+ known_objects.add(value)
+ if op=='give':known_actors.add(extra[0])
+ if op=='copy':
+ cell=world.cells.get(name(value))
+ if cell is None or len(cell.locations)!=1:raise TaskError('A copied location is not uniquely known at that event.')
+ place=next(iter(cell.locations));event_text=f'{name(actor)} went to {place}'
+ elif op=='from':
+ cell=world.cells.get(name(actor))
+ 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=='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+'.')
+ target_value=query[1]
+ if target_value in ('he','she','they','it'):
+ referents=known_objects if query[0]=='owner' else known_actors
+ if len(referents)!=1:raise TaskError('The question pronoun has no unique referent.')
+ target_value=next(iter(referents))
+ target=name(target_value);q=('Where is '+target if query[0]=='location' else 'Who holds '+target)+'?'
+ result=WorldCA(reasoner().model).query(' '.join(canonical),q)
+ value=back.get(result['answer'],result['answer'])
+ return Result(value,'state',{'coverage':spans(text,parts),'events':events,'reverse_source_order':reverse,
+ 'canonical_events':canonical,'query':query,'proof':result,'symbols':mapping})
|