/ test / analyzer.spec.js
analyzer.spec.js
   1  import { Link, $, API, Memory, Analyzer, Task } from './lib.js'
   2  import * as Inspector from './inspector.js'
   3  
   4  /**
   5   * @type {import('entail').Suite}
   6   */
   7  export const testAnalyzer = {
   8    'plans negation last': async (assert) => {
   9      const plan = Analyzer.rule({
  10        match: { child: $.child, uncle: $.uncle },
  11        when: {
  12          where: [
  13            { match: { the: 'semantic/type', of: $.child, is: 'child' } },
  14            { match: { the: 'relation/nephew', of: $.uncle, is: $.child } },
  15            {
  16              not: { match: { the: 'legal/guardian', of: $.child, is: $.uncle } },
  17            },
  18          ],
  19        },
  20      })
  21        .apply({ child: $.child, uncle: $.uncle })
  22        .prepare()
  23  
  24      assert.deepEqual(plan.toJSON(), {
  25        match: { child: $.child, uncle: $.uncle },
  26        rule: {
  27          match: { child: $.child, uncle: $.uncle },
  28          when: {
  29            where: [
  30              { match: { the: 'semantic/type', of: $.child, is: 'child' } },
  31              { match: { the: 'relation/nephew', of: $.uncle, is: $.child } },
  32              {
  33                not: {
  34                  match: { the: 'legal/guardian', of: $.child, is: $.uncle },
  35                },
  36              },
  37            ],
  38          },
  39        },
  40      })
  41    },
  42  
  43    'negation considered across scopes': async (assert) => {
  44      const Allowed = /** @type {const} */ ({
  45        match: { this: $.x },
  46        when: {
  47          draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }],
  48          activeOwner: [
  49            { match: { the: 'owner', of: $.x, is: $.user } },
  50            { not: { match: { the: 'status', of: $.user, is: 'blocked' } } },
  51          ],
  52        },
  53      })
  54  
  55      const plan = Analyzer.rule({
  56        match: { x: $.x },
  57        when: {
  58          where: [
  59            { match: { the: 'type', of: $.x, is: 'doc' } },
  60            { match: { this: $.x }, rule: Allowed },
  61          ],
  62        },
  63      })
  64        .apply({ x: $.y })
  65        .prepare()
  66  
  67      assert.deepEqual(plan.toJSON(), {
  68        match: { x: $.y },
  69        rule: {
  70          match: { x: $.x },
  71          when: {
  72            where: [
  73              { match: { the: 'type', of: $.x, is: 'doc' } },
  74              {
  75                match: { this: $.x },
  76                rule: {
  77                  match: { this: $.x },
  78                  when: {
  79                    draft: [
  80                      {
  81                        match: {
  82                          the: 'status',
  83                          of: $.x,
  84                          is: 'draft',
  85                        },
  86                      },
  87                    ],
  88                    activeOwner: [
  89                      {
  90                        match: {
  91                          the: 'owner',
  92                          of: $.x,
  93                          is: $.user,
  94                        },
  95                      },
  96                      {
  97                        not: {
  98                          match: {
  99                            the: 'status',
 100                            of: $.user,
 101                            is: 'blocked',
 102                          },
 103                        },
 104                      },
 105                    ],
 106                  },
 107                },
 108              },
 109            ],
 110          },
 111        },
 112      })
 113    },
 114  
 115    'variables get bound before used in some disjuncts': async (assert) => {
 116      const Allowed = /** @type {const} */ ({
 117        match: { this: $.x },
 118        when: {
 119          draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }],
 120          activeOwner: [
 121            { match: { the: 'owner', of: $.x, is: $.user } },
 122            { not: { match: { the: 'status', of: $.user, is: 'blocked' } } },
 123          ],
 124        },
 125      })
 126  
 127      const Test = /** @type {const} */ ({
 128        match: { x: $.x },
 129        when: {
 130          where: [
 131            { match: { this: $.x }, rule: Allowed },
 132            { match: { the: 'type', of: $.x, is: 'doc' } },
 133            { match: { the: 'dept', of: $.user, is: 'eng' } },
 134          ],
 135        },
 136      })
 137  
 138      const plan = Analyzer.rule(Test).apply({ x: $.myX }).prepare()
 139  
 140      assert.deepEqual(plan.toJSON(), {
 141        match: { x: $.myX },
 142        rule: {
 143          match: { x: $.x },
 144          when: {
 145            where: [
 146              { match: { the: 'type', of: $.x, is: 'doc' } },
 147              {
 148                match: { this: $.x },
 149                rule: {
 150                  match: { this: $.x },
 151                  when: {
 152                    draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }],
 153                    activeOwner: [
 154                      { match: { the: 'owner', of: $.x, is: $.user } },
 155                      {
 156                        not: {
 157                          match: { the: 'status', of: $.user, is: 'blocked' },
 158                        },
 159                      },
 160                    ],
 161                  },
 162                },
 163              },
 164              { match: { the: 'dept', of: $.user, is: 'eng' } },
 165            ],
 166          },
 167        },
 168      })
 169    },
 170  
 171    'plans execution by cost': async (assert) => {
 172      const plan = Analyzer.rule({
 173        match: { title: $.title, actor: $.actor },
 174        when: {
 175          where: [
 176            { match: { the: 'movie/title', of: $.movie, is: $.title } },
 177            { match: { the: 'movie/cast', of: $.movie, is: $.actor } },
 178            {
 179              match: {
 180                the: 'person/name',
 181                of: $.actor,
 182                is: 'Arnold Schwarzenegger',
 183              },
 184            },
 185          ],
 186        },
 187      })
 188        .apply({ title: $.title, actor: $.actor })
 189        .prepare()
 190  
 191      assert.deepEqual(plan.toJSON(), {
 192        match: { title: $.title, actor: $.actor },
 193        rule: {
 194          match: { title: $.title, actor: $.actor },
 195          when: {
 196            where: [
 197              {
 198                match: {
 199                  the: 'person/name',
 200                  of: $.actor,
 201                  is: 'Arnold Schwarzenegger',
 202                },
 203              },
 204              { match: { the: 'movie/cast', of: $.movie, is: $.actor } },
 205              { match: { the: 'movie/title', of: $.movie, is: $.title } },
 206            ],
 207          },
 208        },
 209      })
 210    },
 211  
 212    'nested Not considers outer scope': async (assert) => {
 213      const plan = Analyzer.rule({
 214        match: { doc: $.doc },
 215        when: {
 216          published: [
 217            { match: { the: 'type', of: $.doc, is: 'document' } },
 218            { match: { the: 'status', of: $.doc, is: 'published' } },
 219          ],
 220          draft: [
 221            { match: { the: 'type', of: $.doc, is: 'document' } },
 222            { match: { the: 'draft', of: $.doc, is: $.version } },
 223            {
 224              not: {
 225                match: { the: 'approved-by', of: $.version, is: $.reviewer },
 226              },
 227            },
 228            { match: { the: 'role', of: $.reviewer, is: 'editor' } },
 229          ],
 230        },
 231      })
 232        .apply({ doc: $.doc })
 233        .prepare()
 234  
 235      assert.deepEqual(
 236        plan.toJSON(),
 237        {
 238          match: { doc: $.doc },
 239          rule: {
 240            match: { doc: $.doc },
 241            when: {
 242              published: [
 243                { match: { the: 'type', of: $.doc, is: 'document' } },
 244                { match: { the: 'status', of: $.doc, is: 'published' } },
 245              ],
 246              draft: [
 247                { match: { the: 'type', of: $.doc, is: 'document' } },
 248                { match: { the: 'draft', of: $.doc, is: $.version } },
 249                { match: { the: 'role', of: $.reviewer, is: 'editor' } },
 250                {
 251                  not: {
 252                    match: { the: 'approved-by', of: $.version, is: $.reviewer },
 253                  },
 254                },
 255              ],
 256            },
 257          },
 258        },
 259        'Verify Not runs after reviewer role is established'
 260      )
 261    },
 262  
 263    'handles multiple Or branches with different locals': async (assert) => {
 264      const plan = Analyzer.rule({
 265        match: { x: $.x },
 266        when: {
 267          author: [
 268            { match: { the: 'author', of: $.x, is: $.author1 } },
 269            { match: { the: 'department', of: $.author1, is: 'eng' } },
 270          ],
 271          reviewer: [
 272            { match: { the: 'reviewer', of: $.x, is: $.reviewer2 } },
 273            { match: { the: 'level', of: $.reviewer2, is: 'senior' } },
 274          ],
 275        },
 276      })
 277        .apply({ x: $.doc })
 278        .prepare()
 279  
 280      assert.deepEqual(plan.toJSON(), {
 281        match: { x: $.doc },
 282        rule: {
 283          match: { x: $.x },
 284          when: {
 285            author: [
 286              { match: { the: 'department', of: $.author1, is: 'eng' } },
 287              { match: { the: 'author', of: $.x, is: $.author1 } },
 288            ],
 289            reviewer: [
 290              { match: { the: 'level', of: $.reviewer2, is: 'senior' } },
 291              { match: { the: 'reviewer', of: $.x, is: $.reviewer2 } },
 292            ],
 293          },
 294        },
 295      })
 296    },
 297  
 298    'handles multiple negations across scopes': async (assert) => {
 299      const plan = Analyzer.rule({
 300        match: { doc: $.doc },
 301        when: {
 302          branch1: [
 303            { match: { the: 'status', of: $.doc, is: 'draft' } },
 304            { not: { match: { the: 'deleted', of: $.doc, is: true } } },
 305            { match: { the: 'author', of: $.doc, is: $.user } },
 306          ],
 307          branch2: [
 308            { not: { match: { the: 'archived', of: $.team, is: true } } },
 309            { match: { the: 'status', of: $.doc, is: 'draft' } },
 310            { not: { match: { the: 'deleted', of: $.doc, is: true } } },
 311            { match: { the: 'team', of: $.doc, is: $.team } },
 312          ],
 313        },
 314      })
 315        .apply({ doc: $.document })
 316        .prepare()
 317  
 318      assert.deepEqual(
 319        plan.toJSON(),
 320        {
 321          match: { doc: $.document },
 322          rule: {
 323            match: { doc: $.doc },
 324            when: {
 325              branch1: [
 326                { match: { the: 'status', of: $.doc, is: 'draft' } },
 327                { not: { match: { the: 'deleted', of: $.doc, is: true } } },
 328                { match: { the: 'author', of: $.doc, is: $.user } },
 329              ],
 330              branch2: [
 331                { match: { the: 'status', of: $.doc, is: 'draft' } },
 332                { not: { match: { the: 'deleted', of: $.doc, is: true } } },
 333                { match: { the: 'team', of: $.doc, is: $.team } },
 334                { not: { match: { the: 'archived', of: $.team, is: true } } },
 335              ],
 336            },
 337          },
 338        },
 339        'Verify both negations run after their dependencies'
 340      )
 341    },
 342  
 343    'plans operations requiring shared variables': async (assert) => {
 344      const plan = Analyzer.rule({
 345        match: { x: $.x, user: $.user },
 346        when: {
 347          where: [
 348            { match: { the: 'role', of: $.user, is: 'admin' } },
 349            { match: { the: 'review', of: $.x, is: $.review } },
 350            { match: { the: 'type', of: $.x, is: 'doc' } },
 351            { match: { the: 'owner', of: $.x, is: $.user } },
 352            { match: { the: 'status', of: $.x, is: 'draft' } },
 353          ],
 354        },
 355      })
 356        .apply({ x: $.x, user: $.user })
 357        .prepare()
 358  
 359      assert.deepEqual(plan.toJSON(), {
 360        match: { x: $.x, user: $.user },
 361        rule: {
 362          match: { x: $.x, user: $.user },
 363          when: {
 364            where: [
 365              { match: { the: 'role', of: $.user, is: 'admin' } },
 366              { match: { the: 'type', of: $.x, is: 'doc' } },
 367              { match: { the: 'owner', of: $.x, is: $.user } },
 368              { match: { the: 'status', of: $.x, is: 'draft' } },
 369              { match: { the: 'review', of: $.x, is: $.review } },
 370            ],
 371          },
 372        },
 373      })
 374    },
 375  
 376    'handles Match clauses with variable dependencies': async (assert) => {
 377      const plan = Analyzer.rule({
 378        match: { doc: $.doc, count: $.count, size: $.size },
 379        when: {
 380          where: [
 381            { match: { of: $.count, is: $.size }, operator: 'text/length' },
 382            { match: { of: $.size, is: 1000 }, operator: '==' },
 383            { match: { the: 'word-count', of: $.doc, is: $.count } },
 384          ],
 385        },
 386      })
 387        .apply({ doc: $.doc, count: $.count, size: $.size })
 388        .prepare()
 389  
 390      assert.deepEqual(plan.toJSON(), {
 391        match: { doc: $.doc, count: $.count, size: $.size },
 392        rule: {
 393          match: { doc: $.doc, count: $.count, size: $.size },
 394          when: {
 395            where: [
 396              { match: { the: 'word-count', of: $.doc, is: $.count } },
 397              { match: { of: $.count, is: $.size }, operator: 'text/length' },
 398              { match: { of: $.size, is: 1000 }, operator: '==' },
 399            ],
 400          },
 401        },
 402      })
 403    },
 404  
 405    'fails if variable is not defined': async (assert) => {
 406      assert.throws(() => {
 407        const plan = Analyzer.rule({
 408          match: { doc: $.doc },
 409          when: {
 410            where: [
 411              { match: { the: 'status', of: $.doc, is: 'ready' } },
 412              { match: { of: $.user, is: 'admin' }, operator: '==' },
 413            ],
 414          },
 415        })
 416          .apply({ doc: $.doc })
 417          .prepare()
 418      }, /Unbound \?user variable referenced from { match: { of: \$.user, is: "admin" }, operator: "==" }/)
 419    },
 420  
 421    'terms in match affect planning': async (assert) => {
 422      const plan = Analyzer.rule({
 423        match: { user: $.user },
 424        when: {
 425          author: [
 426            { match: { the: 'draft', of: $.doc, is: $.version } },
 427            { match: { the: 'author', of: $.doc, is: $.user } },
 428          ],
 429          reviewer: [{ match: { the: 'reviewer', of: $.doc, is: $.user } }],
 430        },
 431      })
 432        .apply({ user: 1 })
 433        .prepare()
 434  
 435      assert.deepEqual(plan.toJSON(), {
 436        match: { user: 1 },
 437        rule: {
 438          match: { user: $.user },
 439          when: {
 440            author: [
 441              { match: { the: 'author', of: $.doc, is: $.user } },
 442              { match: { the: 'draft', of: $.doc, is: $.version } },
 443            ],
 444            reviewer: [{ match: { the: 'reviewer', of: $.doc, is: $.user } }],
 445          },
 446        },
 447      })
 448    },
 449  
 450    'fails on unknown variable reference': async (assert) => {
 451      assert.throws(() => {
 452        const plan = Analyzer.rule({
 453          match: { doc: $.doc },
 454          when: {
 455            branch1: [
 456              { match: { of: $.count, is: 100 }, operator: '==' },
 457              { match: { the: 'type', of: $.doc, is: 'counter' } },
 458            ],
 459            branch2: [{ match: { the: 'type', of: $.doc, is: 'doc' } }],
 460          },
 461        })
 462          .apply({ doc: $.doc })
 463          .prepare()
 464      }, /Unbound \?count variable referenced from { match: { of: \$.count, is: 100 }, operator: "==" }/)
 465    },
 466  
 467    'correctly maps variables across scopes': async (assert) => {
 468      const plan = Analyzer.rule({
 469        match: { x: $.x, y: $.y },
 470        when: {
 471          where: [
 472            { match: { the: 'type', of: $.y, is: 'person' } },
 473            { match: { the: 'name', of: $.y, is: $.x } },
 474          ],
 475        },
 476      })
 477        .apply({ x: $.y, y: $.x })
 478        .prepare()
 479  
 480      assert.deepEqual(plan.toJSON(), {
 481        match: { x: $.y, y: $.x },
 482        rule: {
 483          match: { x: $.x, y: $.y },
 484          when: {
 485            where: [
 486              { match: { the: 'type', of: $.y, is: 'person' } },
 487              { match: { the: 'name', of: $.y, is: $.x } },
 488            ],
 489          },
 490        },
 491      })
 492    },
 493  
 494    'throws if rule does not bind a variable': async (assert) => {
 495      assert.throws(
 496        () =>
 497          Analyzer.rule({
 498            match: { x: $.x, y: $.y },
 499            when: { where: [{ match: { the: 'type', of: $.y, is: 'person' } }] },
 500          })
 501            .apply({ x: $.output, y: $.input })
 502            .prepare(),
 503        /Rule case "where" does not bind variable \?x that rule matches as "x"/
 504      )
 505    },
 506  
 507    'throws if rule branch does not handle match variable': async (assert) => {
 508      assert.throws(
 509        () =>
 510          Analyzer.rule({
 511            match: { x: $.x, y: $.y },
 512            when: {
 513              base: [
 514                { match: { the: 'type', of: $.y, is: 'person' } },
 515                // Missing handling of $.x
 516              ],
 517            },
 518          })
 519            .apply({ x: $.output, y: $.input })
 520            .prepare(),
 521        /Rule case "base" does not bind variable \?x that rule matches as "x"/
 522      )
 523    },
 524  
 525    'skip recursive rule must have a non-recursive branch': async (assert) => {
 526      assert.throws(
 527        () =>
 528          Analyzer.rule({
 529            match: { x: $.x },
 530            when: {
 531              loop: [{ recur: { x: $.x } }],
 532            },
 533          })
 534            .apply({ x: $.who })
 535            .prepare(),
 536        /Recursive rule must have at least one non-recursive branch/
 537      )
 538    },
 539  
 540    'skip recursive rule must have non-recursive branch': async (assert) => {
 541      assert.throws(
 542        () =>
 543          Analyzer.rule({
 544            match: { x: $.x },
 545            when: {
 546              other: [
 547                { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 548                { recur: { x: $.y } },
 549              ],
 550              loop: [{ recur: { x: $.x } }],
 551            },
 552          })
 553            .apply({ x: 5 })
 554            .prepare(),
 555        /Recursive rule must have at least one non-recursive branch/
 556      )
 557    },
 558  
 559    'prefers efficient execution path based on bindings': async (assert) => {
 560      /**
 561       * @param {API.Term} x
 562       * @returns
 563       */
 564      const make = (x) =>
 565        Analyzer.rule({
 566          match: { x: $.x, y: $.y },
 567          when: {
 568            where: [
 569              { match: { of: $.type, is: 'type' }, operator: '==' },
 570              { match: { the: $.type, of: $.y, is: 'person' } },
 571              { match: { the: 'name', of: $.y, is: $.x } },
 572            ],
 573          },
 574        })
 575          .apply({ x, y: $.output })
 576          .prepare()
 577  
 578      assert.deepEqual(
 579        make($.input).toJSON(),
 580        {
 581          match: { x: $.input, y: $.output },
 582          rule: {
 583            match: { x: $.x, y: $.y },
 584            when: {
 585              where: [
 586                { match: { the: $.type, of: $.y, is: 'person' } },
 587                { match: { of: $.type, is: 'type' }, operator: '==' },
 588                { match: { the: 'name', of: $.y, is: $.x } },
 589              ],
 590            },
 591          },
 592        },
 593        'without bindings order remains same'
 594      )
 595  
 596      assert.deepEqual(
 597        make('John').toJSON(),
 598        {
 599          match: { x: 'John', y: $.output },
 600          rule: {
 601            match: { x: $.x, y: $.y },
 602            when: {
 603              where: [
 604                { match: { the: 'name', of: $.y, is: $.x } },
 605                { match: { the: $.type, of: $.y, is: 'person' } },
 606                { match: { of: $.type, is: 'type' }, operator: '==' },
 607              ],
 608            },
 609          },
 610        },
 611        'with bindings plans more more efficiently'
 612      )
 613    },
 614  
 615    'estimates costs across complex rule paths': async (assert) => {
 616      /**
 617       * @param {API.Term} person
 618       */
 619      const plan = (person) =>
 620        Analyzer.rule({
 621          match: { person: $.person },
 622          when: {
 623            manager: [{ match: { the: 'role', of: $.person, is: 'manager' } }],
 624            senior: [
 625              { match: { the: 'role', of: $.person, is: 'employee' } },
 626              { match: { the: 'level', of: $.person, is: 'senior' } },
 627            ],
 628          },
 629        })
 630          .apply({ person })
 631          .prepare()
 632  
 633      assert.ok(plan($.who).cost > plan('Alice').cost)
 634    },
 635  
 636    'ensures rule scope is independent from outer scope': async (assert) => {
 637      /**
 638       * @param {API.Term} result
 639       */
 640      const plan = (result) =>
 641        Analyzer.rule({
 642          match: { result: $.result },
 643          when: {
 644            ref: [
 645              {
 646                match: { of: $.result, is: 'reference' },
 647                operator: 'data/type',
 648              },
 649            ],
 650            else: [
 651              { match: { of: $.type, is: 'b' }, operator: '==' },
 652              { match: { the: $.type, of: $.x, is: 'b' } },
 653              { match: { the: 'value', of: $.result, is: $.x } },
 654            ],
 655          },
 656        })
 657          .apply({ result })
 658          .prepare()
 659  
 660      assert.deepEqual(plan('data').toJSON(), {
 661        match: { result: 'data' },
 662        rule: {
 663          match: { result: $.result },
 664          when: {
 665            ref: [
 666              { match: { of: $.result, is: 'reference' }, operator: 'data/type' },
 667            ],
 668            else: [
 669              { match: { the: 'value', of: $.result, is: $.x } },
 670              { match: { the: $.type, of: $.x, is: 'b' } },
 671              { match: { of: $.type, is: 'b' }, operator: '==' },
 672            ],
 673          },
 674        },
 675      })
 676  
 677      assert.throws(
 678        () => plan($.q),
 679        /Unbound \?result variable referenced from { match: { of: \$.result, is: "reference" }, operator: "data\/type" }/
 680      )
 681    },
 682  
 683    'handles rule variable mappings correctly': async (assert) => {
 684      assert.throws(
 685        () =>
 686          Analyzer.rule({
 687            match: { x: $.x, y: $.y },
 688            when: {
 689              where: [{ match: { this: $.x, than: $.y }, operator: '>' }],
 690            },
 691          })
 692            // @ts-expect-error - missing match for y
 693            .apply({ x: $.input })
 694            .prepare(),
 695  
 696        // Analyzer.from({
 697        //   match: { x: $.input },
 698        //   rule: {
 699        //     match: { x: $.x, y: $.y },
 700        //     when: [{ match: { this: $.x, than: $.y }, operator: '>' }],
 701        //   },
 702        // })
 703        /Rule application omits required parameter "y"/
 704      )
 705    },
 706  
 707    'rule output may be provided': async (assert) => {
 708      const rule = Analyzer.rule({
 709        match: { x: $.x, y: $.y },
 710        when: {
 711          where: [{ match: { of: $.x, by: 1, is: $.y }, operator: '-' }],
 712        },
 713      })
 714  
 715      const application = rule.apply({ x: $.outX, y: $.outY })
 716  
 717      assert.throws(() => {
 718        application.prepare()
 719      }, /Unbound \?x variable referenced from { match: { of: \$.x, by: 1, is: \$.y }, operator: "-" }/)
 720  
 721      assert.ok(rule.apply({ x: null, y: $.unbound }).prepare().toJSON())
 722      assert.ok(rule.apply({ x: 1, y: 2 }).prepare().toJSON())
 723    },
 724  
 725    'rule maps multi-variable input terms correctly': async (assert) => {
 726      const rule = Analyzer.rule({
 727        match: { x: $.a, y: $.b },
 728        when: {
 729          where: [
 730            {
 731              match: { of: $.a, with: $.b, is: $.result },
 732              operator: 'text/concat',
 733            },
 734          ],
 735        },
 736      })
 737  
 738      assert.deepEqual(rule.apply({ x: 1, y: 2 }).prepare().toJSON(), {
 739        match: { x: 1, y: 2 },
 740        rule: {
 741          match: { x: $.a, y: $.b },
 742          when: {
 743            where: [
 744              {
 745                match: { of: $.a, with: $.b, is: $.result },
 746                operator: 'text/concat',
 747              },
 748            ],
 749          },
 750        },
 751      })
 752  
 753      assert.throws(
 754        () => rule.apply({ x: 1, y: $.unbound }).prepare(),
 755        /Unbound \?b variable referenced/
 756      )
 757  
 758      assert.throws(
 759        () => rule.apply({ y: 1, x: $.unbound }).prepare(),
 760        /Unbound \?a variable referenced/
 761      )
 762    },
 763  
 764    'handles unified variables in rule case': async (assert) => {
 765      const Same = Analyzer.rule({
 766        match: { this: $.a, as: $.a },
 767      })
 768  
 769      assert.deepEqual(Same.apply({ this: 1, as: $.q }).prepare().toJSON(), {
 770        match: { this: 1, as: $.q },
 771        rule: {
 772          match: { this: $.a, as: $.a },
 773        },
 774      })
 775  
 776      assert.deepEqual(Same.apply({ this: $.q, as: 2 }).prepare().toJSON(), {
 777        match: { this: $.q, as: 2 },
 778        rule: {
 779          match: { this: $.a, as: $.a },
 780        },
 781      })
 782  
 783      assert.throws(
 784        () => Same.apply({ this: $.x, as: $.y }).prepare(),
 785        /Rule application requires binding for \?a/
 786      )
 787    },
 788  
 789    'unification + input': async (assert) => {
 790      const rule = Analyzer.rule({
 791        match: { a: $.a, b: $.a, c: $.c }, // Same variable $.a in both positions
 792        when: {
 793          where: [{ match: { of: $.a, is: $.c }, operator: '==' }],
 794        },
 795      })
 796  
 797      assert.ok(
 798        rule.apply({ a: 1, b: 1, c: $.unbound }).prepare().cost >=
 799          rule.apply({ a: 1, b: 1, c: 1 }).prepare().cost,
 800        'output cell can be omitted'
 801      )
 802    },
 803  
 804    'errors if rule branch references undefined variable': async (assert) => {
 805      const rule = Analyzer.rule({
 806        match: { x: $.x, y: $.y },
 807        when: {
 808          where: [
 809            {
 810              match: { of: $.z, with: $.y, is: $.x }, // $.z not in case
 811              operator: '+',
 812            },
 813          ],
 814        },
 815      })
 816  
 817      const match = rule.apply({ x: $.a, y: $.b })
 818  
 819      assert.throws(
 820        () => rule.apply({ x: 'a', y: 'b' }).prepare(),
 821        /Unbound \?z variable referenced from/
 822      )
 823    },
 824  
 825    'errors if deductive branch does not handle case variable': async (
 826      assert
 827    ) => {
 828      assert.throws(
 829        () =>
 830          Analyzer.rule({
 831            match: { x: $.x, y: $.y },
 832            when: {
 833              where: [
 834                { match: { the: 'type', of: $.x, is: 'person' } }, // Doesn't handle $.y
 835              ],
 836            },
 837          })
 838            .apply({ x: 'x', y: $.out })
 839            .prepare(),
 840        /Rule case "where" does not bind variable \?y that rule matches as "y"/
 841      )
 842    },
 843  
 844    'recursive rule must have when that binds all variables': async (assert) => {
 845      assert.throws(
 846        () =>
 847          Analyzer.rule({
 848            match: { this: $.value, from: $.from, to: $.to },
 849            when: {
 850              do: [{ match: { this: $.from, than: $.to }, operator: '<' }],
 851              while: [
 852                { match: { of: $.from, with: 1, is: $.inc }, operator: '+' },
 853                { match: { this: $.inc, than: $.to }, operator: '<' },
 854              ],
 855            },
 856          }),
 857        /does not bind variable \?value that rule matches as "this"/
 858      )
 859    },
 860  
 861    'allows output variables to be omitted from match': async (assert) => {
 862      const plan = Analyzer.rule({
 863        match: { x: $.x, y: $.y },
 864        when: {
 865          where: [{ match: { of: $.x, is: $.y }, operator: 'math/absolute' }],
 866        },
 867      })
 868        // @ts-expect-error - missing y
 869        .apply({ x: 'test' })
 870        .prepare()
 871  
 872      assert.ok(plan, 'Should allow omitting output variables')
 873    },
 874  
 875    'detects cycles between branches': async (assert) => {
 876      const rule = Analyzer.rule({
 877        match: { x: $.x, y: $.y },
 878        when: {
 879          where: [
 880            { match: { of: $.y, is: $.x }, operator: 'math/absolute' },
 881            { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 882          ],
 883        },
 884      })
 885  
 886      assert.throws(() => {
 887        rule.apply({ x: $.in, y: $.out }).prepare()
 888      }, /Unbound \?y variable referenced from { match: { of: \$.y, is: \$.x }, operator: "math\/absolute" }/)
 889  
 890      assert.deepEqual(
 891        rule.apply({ x: 1, y: $.out }).prepare().toJSON(),
 892        {
 893          match: { x: 1, y: $.out },
 894          rule: {
 895            match: { x: $.x, y: $.y },
 896            when: {
 897              where: [
 898                { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 899                { match: { of: $.y, is: $.x }, operator: 'math/absolute' },
 900              ],
 901            },
 902          },
 903        },
 904        'resolves cycle through x'
 905      )
 906  
 907      assert.deepEqual(
 908        rule.apply({ x: $.q, y: 1 }).prepare().toJSON(),
 909        {
 910          match: { x: $.q, y: 1 },
 911          rule: {
 912            match: { x: $.x, y: $.y },
 913            when: {
 914              where: [
 915                { match: { of: $.y, is: $.x }, operator: 'math/absolute' },
 916                { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 917              ],
 918            },
 919          },
 920        },
 921        'resolves cycle through y'
 922      )
 923    },
 924  
 925    'resoles cycles from application': async (assert) => {
 926      const rule = Analyzer.rule({
 927        match: { x: $.x, y: $.y },
 928        when: {
 929          where: [
 930            { match: { the: 'type', of: $.x, is: 'person' } }, // Outputs $.x
 931            { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, // Uses $.x to produce $.y
 932            { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, // Creates cycle by producing $.x again
 933          ],
 934        },
 935      })
 936  
 937      assert.deepEqual(rule.apply({ x: $.outX, y: $.outY }).prepare().toJSON(), {
 938        match: { x: $.outX, y: $.outY },
 939        rule: {
 940          match: { x: $.x, y: $.y },
 941          when: {
 942            where: [
 943              { match: { the: 'type', of: $.x, is: 'person' } },
 944              { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 945              { match: { of: $.y, by: 1, is: $.x }, operator: '-' },
 946            ],
 947          },
 948        },
 949      })
 950    },
 951  
 952    'unresolvable cycles': async (assert) => {
 953      const rule = Analyzer.rule({
 954        match: { is: $.is },
 955        when: {
 956          where: [
 957            { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 958            { match: { of: $.y, by: 1, is: $.x }, operator: '-' },
 959            { match: { of: $.x, is: $.is }, operator: '==' },
 960          ],
 961        },
 962      })
 963  
 964      assert.throws(
 965        () => rule.apply({ is: $.q }).prepare(),
 966        /Unbound \?x variable referenced from \{ match: { of: \$.x, with: 1, is: \$.y }, operator: "\+"/
 967      )
 968    },
 969  
 970    'resolvable through unification': async (assert) => {
 971      const rule = Analyzer.rule({
 972        match: { is: $.is },
 973        when: {
 974          where: [
 975            { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 976            { match: { of: $.y, by: 1, is: $.x }, operator: '-' },
 977            {
 978              match: { this: $.x, as: $.is },
 979              rule: { match: { this: $.as, as: $.as }, when: {} },
 980            },
 981          ],
 982        },
 983      })
 984  
 985      assert.deepEqual(
 986        rule.apply({ is: 5 }).prepare().toJSON(),
 987        {
 988          match: { is: 5 },
 989          rule: {
 990            match: { is: $.is },
 991            when: {
 992              where: [
 993                {
 994                  match: { this: $.x, as: $.is },
 995                  rule: { match: { this: $.as, as: $.as } },
 996                },
 997                { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
 998                { match: { of: $.y, by: 1, is: $.x }, operator: '-' },
 999              ],
1000            },
1001          },
1002        },
1003        'resolves cycle'
1004      )
1005  
1006      assert.throws(
1007        () => rule.apply({ is: $.q }).prepare(),
1008        /Unbound \?x variable/
1009      )
1010    },
1011  
1012    'cycle in formula': async (assert) => {
1013      assert.throws(() => {
1014        Analyzer.rule({
1015          match: { x: $.x },
1016          when: {
1017            where: [
1018              {
1019                match: { of: $.x, with: 1, is: $.x },
1020                operator: '+',
1021              },
1022            ],
1023          },
1024        })
1025          .apply({ x: $.x })
1026          .prepare()
1027      }, /Variable .* cannot appear in both input and output of Match clause/)
1028    },
1029  
1030    'cycles between disjunctive branches are valid': async (assert) => {
1031      const plan = Analyzer.rule({
1032        match: { x: $.x, y: $.y },
1033        when: {
1034          branch1: [
1035            {
1036              match: { of: $.x, with: 1, is: $.y },
1037              operator: '+',
1038            },
1039          ],
1040          branch2: [{ match: { of: $.y, by: 1, is: $.x }, operator: '-' }],
1041        },
1042      })
1043        .apply({ x: 'x', y: 'y' })
1044        .prepare()
1045  
1046      assert.ok(plan, 'Rule should plan successfully')
1047    },
1048  
1049    'plans rule based on available bindings': async (assert) => {
1050      const plan = Analyzer.rule({
1051        match: { x: $.x },
1052        when: {
1053          where: [
1054            { match: { the: 'type', of: $.x, is: 'person' } },
1055            { match: { the: 'name', of: $.x, is: $.name } },
1056          ],
1057        },
1058      })
1059        .apply({ x: $.x })
1060        .prepare()
1061  
1062      assert.ok(plan, 'Should plan successfully')
1063    },
1064  
1065    'fails to plan when required inputs missing': async (assert) => {
1066      assert.throws(
1067        () =>
1068          Analyzer.rule({
1069            match: { x: $.x },
1070            when: {
1071              where: [
1072                {
1073                  match: {
1074                    of: $.y, // y is not bound
1075                    with: 1,
1076                    is: $.x,
1077                  },
1078                  operator: '+',
1079                },
1080              ],
1081            },
1082          })
1083            .apply({ x: $.x })
1084            .prepare(),
1085        /Unbound \?y variable referenced from { match: { of: \$.y, with: 1, is: \$.x }, operator: "\+" }/
1086      )
1087    },
1088  
1089    'plans rule when inputs are bound': async (assert) => {
1090      const rule = Analyzer.rule({
1091        match: { x: $.x, y: $.y },
1092        when: {
1093          where: [
1094            // Needs $.x to produce $.y
1095            { match: { of: $.x, with: 1, is: $.y }, operator: '+' },
1096          ],
1097        },
1098      })
1099  
1100      const match = rule.apply({ x: $.in, y: $.out })
1101  
1102      assert.ok(
1103        rule.apply({ x: 'in', y: 'out' }).prepare(),
1104        'Plans when input is bound'
1105      )
1106      assert.throws(
1107        () => rule.apply({ x: $.x, y: 'out' }).prepare(),
1108        /Unbound \?x variable referenced from { match: { of: \$.x, with: 1, is: \$.y }, operator: "\+" }/
1109      )
1110    },
1111  
1112    'inflates rule cost based on recursion': async (assert) => {
1113      /**
1114       * @param {object} source
1115       * @param {API.Term} source.from
1116       * @param {API.Term} source.to
1117       * @returns
1118       */
1119      const Recursive = ({ from, to }) =>
1120        Analyzer.rule({
1121          match: { n: $.n, from: $.from, to: $.to },
1122          when: {
1123            do: [
1124              { match: { this: $.from, than: $.to }, operator: '<' },
1125              { match: { of: $.from, is: $.n }, operator: '==' },
1126            ],
1127            while: [
1128              { match: { this: $.from, than: $.to }, operator: '<' },
1129              { match: { of: $.from, with: 1, is: $.inc }, operator: '+' },
1130              { recur: { n: $.n, from: $.inc, to: $.to } },
1131            ],
1132          },
1133        })
1134          .apply({ n: $.n, from, to })
1135          .prepare()
1136  
1137      /**
1138       * @param {object} source
1139       * @param {API.Term} source.from
1140       * @param {API.Term} source.to
1141       * @returns
1142       */
1143      const Deductive = ({ from, to }) =>
1144        Analyzer.rule({
1145          match: { n: $.n, from: $.from, to: $.to },
1146          when: {
1147            base: [
1148              { match: { this: $.from, than: $.to }, operator: '<' },
1149              { match: { of: $.from, is: $.n }, operator: '==' },
1150            ],
1151            else: [
1152              { match: { of: $.from, with: 1, is: $.n }, operator: '+' },
1153              { match: { this: $.n, than: $.to }, operator: '<' },
1154            ],
1155          },
1156        })
1157          .apply({ n: $.n, from, to })
1158          .prepare()
1159  
1160      assert.ok(
1161        Recursive({ from: 0, to: 10 }).cost > Deductive({ from: 0, to: 10 }).cost,
1162        `Recursive rule should have higher cost ${
1163          Recursive({ from: 0, to: 10 }).cost
1164        } > ${Deductive({ from: 0, to: 10 }).cost}}`
1165      )
1166    },
1167  
1168    'considers variable mapping in cost estimation': async (assert) => {
1169      const rule = Analyzer.rule({
1170        match: { x: $.x },
1171        when: {
1172          where: [{ match: { of: $.x, with: 1, is: $.y }, operator: '+' }],
1173        },
1174      })
1175  
1176      assert.ok(
1177        rule.apply({ x: 1 }).prepare(),
1178        'Should plan with mapped variables'
1179      )
1180      assert.ok(
1181        rule.apply({ x: 'thing' }).prepare(),
1182        'Should plan with bound variables'
1183      )
1184    },
1185  
1186    'plans rule with no body': async (assert) => {
1187      const Same = Analyzer.rule({
1188        match: { this: $, as: $ },
1189      })
1190  
1191      const plan = Same.apply({ this: $.x, as: $.x }).plan({
1192        bindings: new Map([[$.x, 1]]),
1193        references: new Map(),
1194      })
1195  
1196      assert.ok(
1197        plan.cost < 1,
1198        'Empty rule should have very low cost as it just unifies variables'
1199      )
1200  
1201      assert.throws(
1202        () => Same.apply({ this: $.x, as: $.x }).prepare(),
1203        /Rule application requires binding for \?/
1204      )
1205    },
1206  
1207    'plan blank rules': async (assert) => {
1208      const Entity = Analyzer.rule({
1209        match: { this: $.this },
1210      })
1211  
1212      assert.throws(
1213        () => Entity.apply({ this: $.q }).prepare(),
1214        /Rule application requires binding for \?this/
1215      )
1216    },
1217  
1218    'compares iteration rule cost to against scan cost': async (assert) => {
1219      const Between = Analyzer.rule({
1220        match: {
1221          value: $.value,
1222          from: $.from,
1223          to: $.to,
1224        },
1225        when: {
1226          do: [
1227            { match: { this: $.from, than: $.to }, operator: '<' },
1228            { match: { of: $.from, is: $.value }, operator: '==' },
1229          ],
1230          while: [
1231            { match: { this: $.from, than: $.to }, operator: '<' },
1232            { match: { of: $.from, with: 1, is: $.next }, operator: '+' },
1233            { recur: { value: $.value, from: $.next, to: $.to } },
1234          ],
1235        },
1236      })
1237  
1238      const Scan = Analyzer.rule({
1239        match: { x: $.x },
1240        when: { where: [{ match: { the: 'type', of: $.x, is: 'document' } }] },
1241      })
1242  
1243      const between = Between.apply({ from: 0, to: 100, value: $.n }).prepare()
1244  
1245      const scan = Scan.apply({ x: $.x }).prepare()
1246  
1247      assert.ok(
1248        between.cost < scan.cost,
1249        `Between rule using only formula operations should be cheaper than a full scan ${between.cost} < ${scan.cost}`
1250      )
1251    },
1252  
1253    'estimates costs correctly for Case patterns': async (assert) => {
1254      // Helper to create a test case
1255  
1256      const select = Analyzer.rule({
1257        match: { the: $.attribute, of: $.entity, is: $.value },
1258        when: {
1259          where: [{ match: { the: $.attribute, of: $.entity, is: $.value } }],
1260        },
1261      })
1262  
1263      /**
1264       * @param {Omit<Required<API.Select>, 'this'>} terms
1265       * @param {Partial<API.Scope>} context
1266       */
1267      const testCost = (
1268        terms,
1269        { bindings = new Map(), references = new Map() } = {}
1270      ) =>
1271        select.apply(terms).plan({
1272          bindings,
1273          references,
1274        }).cost
1275  
1276      // Test EAV index cases
1277      const entityId = Link.of('test-entity')
1278      assert.ok(
1279        testCost({ the: 'type', of: entityId, is: $.value }) <
1280          testCost({ the: 'type', of: $.entity, is: $.value }),
1281        'Known entity should be cheaper than unknown'
1282      )
1283  
1284      // Test attribute selectivity
1285      assert.ok(
1286        testCost({ the: 'type', of: $.entity, is: $.value }) <
1287          testCost({ the: $.attribute, of: $.entity, is: $.value }),
1288        'Known attribute should be cheaper than unknown'
1289      )
1290  
1291      // Test value types
1292      assert.ok(
1293        testCost({ the: $.attribute, of: $.entity, is: entityId }) ==
1294          testCost({ the: $.attribute, of: $.entity, is: 'some-string' }),
1295  
1296        'Entity value should be as selective as string'
1297      )
1298  
1299      assert.ok(
1300        testCost({ the: $.attribute, of: $.entity, is: 'string' }) ==
1301          testCost({ the: $.attribute, of: $.entity, is: true }),
1302        'String should be as selective as boolean'
1303      )
1304  
1305      // Test index usage
1306      assert.ok(
1307        testCost({ the: 'type', of: entityId, is: $.value }) <
1308          testCost({ the: $.attribute, of: entityId, is: $.value }),
1309        'EAV index should be cheaper than scanning entity'
1310      )
1311  
1312      assert.ok(
1313        testCost({ the: 'type', of: $.entity, is: entityId }) <
1314          testCost({ the: $.attribute, of: $.entity, is: entityId }),
1315        'VAE index should be cheaper than scanning value'
1316      )
1317  
1318      // Test bound variables
1319      assert.ok(
1320        testCost({ the: 'type', of: entityId, is: $.value }) ==
1321          testCost(
1322            { the: 'type', of: $.entity, is: $.value },
1323            {
1324              bindings: new Map([[$.entity, entityId]]),
1325            }
1326          ),
1327        'Known entity should cost same as bound entity variable'
1328      )
1329  
1330      assert.ok(
1331        testCost({ the: 'type', of: $.entity, is: entityId }) ==
1332          testCost(
1333            { the: 'type', of: $.entity, is: $.value },
1334            {
1335              bindings: new Map([[$.value, 2]]),
1336            }
1337          ),
1338        'Known value should cost same as bound value variable'
1339      )
1340    },
1341  
1342    'test select circuit': (assert) => {
1343      const plan = Analyzer.rule({
1344        match: { x: $.x },
1345        when: {
1346          basic: [
1347            { match: { the: 'type', of: $.x, is: 'document' } },
1348            { match: { the: 'status', of: $.x, is: $.status } },
1349          ],
1350          advanced: [
1351            { match: { the: 'type', of: $.x, is: 'document' } },
1352            { match: { the: 'status', of: $.x, is: $.status } },
1353            { match: { the: 'author', of: $.x, is: $.author } },
1354          ],
1355        },
1356      })
1357        .apply({ x: $.x })
1358        .prepare()
1359    },
1360  
1361    'test correctly merges cost estimates': (assert) => {
1362      const rule = Analyzer.rule({
1363        match: { of: $.of },
1364        when: {
1365          where: [
1366            { match: { the: 'name', of: $.of, is: $.name } },
1367            { match: { of: $.name, is: 'string' }, operator: 'data/type' },
1368          ],
1369        },
1370      })
1371  
1372      const application = rule.apply({ of: $.subject })
1373  
1374      assert.ok(application.cost < Infinity)
1375    },
1376  
1377    'unblocks when referenced remote variable is unblocked': (assert) => {
1378      const plan = Analyzer.rule({
1379        match: { actual: $.actual, expect: $.expect },
1380        when: {
1381          where: [
1382            // Here we `$.expect` to be bound by the second conjunct.
1383            { match: { of: $.expect, is: 'actual' }, operator: '==' },
1384            // This sets a binding of the $.actual
1385            { match: { of: 'actual', is: $.actual }, operator: '==' },
1386          ],
1387        },
1388      })
1389        .apply({ actual: $.same, expect: $.same })
1390        .prepare()
1391  
1392      assert.deepEqual(plan.toJSON(), {
1393        match: { actual: $.same, expect: $.same },
1394        rule: {
1395          match: { actual: $.actual, expect: $.expect },
1396          when: {
1397            where: [
1398              { match: { of: 'actual', is: $.actual }, operator: '==' },
1399              { match: { of: $.expect, is: 'actual' }, operator: '==' },
1400            ],
1401          },
1402        },
1403      })
1404    },
1405  
1406    'negation references are inputs': (assert) => {
1407      assert.throws(() => {
1408        const plan = Analyzer.rule({
1409          match: { q: $.q },
1410          when: {
1411            where: [
1412              {
1413                not: { match: { the: 'status/ready', of: $.q } },
1414              },
1415            ],
1416          },
1417        })
1418          .apply({ q: $.q })
1419          .prepare()
1420      }, /Unbound \?q variable/)
1421    },
1422  
1423    'salary variable reuse test': async (assert) => {
1424      const Employee = /** @type {const} */ ({
1425        match: {
1426          this: $.this,
1427          name: $.name,
1428          salary: $.salary,
1429        },
1430        when: {
1431          where: [
1432            { match: { the: 'name', of: $.this, is: $.name } },
1433            { match: { the: 'salary', of: $.this, is: $.salary } },
1434          ],
1435        },
1436      })
1437  
1438      const Supervisor = Analyzer.rule({
1439        match: {
1440          employee: $.employee,
1441          supervisor: $.supervisor,
1442        },
1443        when: {
1444          where: [
1445            // Using the same wildcard variable $._ for two different salary parameters
1446            {
1447              match: { this: $.subordinate, name: $.employee, salary: $._ },
1448              rule: Employee,
1449            },
1450            { match: { the: 'supervisor', of: $.subordinate, is: $.manager } },
1451            {
1452              match: { this: $.manager, name: $.supervisor, salary: $._ },
1453              rule: Employee,
1454            },
1455          ],
1456        },
1457      })
1458  
1459      const plan = Supervisor.apply().prepare()
1460      assert.ok(
1461        plan,
1462        'Should successfully create a plan with reused wildcard variables'
1463      )
1464    },
1465  
1466    'formula input is required': (assert) => {
1467      assert.throws(() => {
1468        const plan = Analyzer.rule({
1469          match: { q: $.q },
1470          when: {
1471            where: [
1472              {
1473                match: { of: $.q, is: 'string' },
1474                operator: 'data/type',
1475              },
1476            ],
1477          },
1478        })
1479          .apply({ q: $.q })
1480          .prepare()
1481      }, /Unbound \?q variable referenced from { match: { of: \$.q, is: "string" }, operator: "data\/type" }/)
1482    },
1483  
1484    'discard variable still fails with required operators': (assert) => {
1485      const rule = Analyzer.rule({
1486        match: { type: $.type },
1487        when: {
1488          where: [
1489            // Using $._ as the required 'of' parameter - this should still fail
1490            { match: { of: $._, is: $.type }, operator: 'data/type' },
1491          ],
1492        },
1493      })
1494  
1495      assert.throws(() => {
1496        rule.apply({ type: 'string' }).prepare()
1497      }, /Unbound \?_ variable referenced from/)
1498    },
1499  
1500    'test select resolution': async (assert) => {
1501      const plan = Analyzer.rule({
1502        match: { person: $.person, name: $.name },
1503        when: {
1504          where: [{ match: { the: 'person/name', of: $.person, is: $.name } }],
1505        },
1506      })
1507        .apply({ person: $.q, name: 'Irakli' })
1508        .prepare()
1509  
1510      const source = Memory.create({
1511        import: {
1512          irakli: { 'person/name': 'Irakli' },
1513          zoe: { 'person/name': 'Zoe' },
1514        },
1515      })
1516      const inspector = Inspector.from(source)
1517      const results = await Task.perform(plan.query({ from: inspector }))
1518  
1519      // console.log([...results.values()])
1520      assert.deepEqual(results, [
1521        new Map([[$.q, Link.of({ 'person/name': 'Irakli' })]]),
1522      ])
1523  
1524      // assert.deepEqual(
1525      //   [...results.entries()],
1526      //   [
1527      //     [$.q, Link.of({ 'person/name': 'Irakli' })],
1528      //     [$.name, 'Irakli'],
1529      //   ]
1530      //   // [{ person: Link.of({ 'person/name': 'Irakli' }), name: 'Irakli' }]
1531      // )
1532  
1533      assert.deepEqual(inspector.queries(), [
1534        { the: 'person/name', is: 'Irakli' },
1535      ])
1536    },
1537  
1538    'plan nested unification': async (assert) => {
1539      const plan = Analyzer.rule({
1540        match: { counter: $.counter },
1541        when: {
1542          where: [
1543            // { not: { match: { the: 'counter/count', of: $.counter } } },
1544            {
1545              match: { this: Link.of({}), as: $.counter },
1546              rule: { match: { this: $.this, as: $.this }, when: {} },
1547            },
1548          ],
1549        },
1550      })
1551        .apply({ counter: $.q })
1552        .prepare()
1553  
1554      assert.ok(plan)
1555    },
1556  
1557    'test unification example': async (assert) => {
1558      const plan = Analyzer.rule({
1559        match: { this: $.a, as: $.a },
1560        when: {},
1561      })
1562        .apply({ this: $.q, as: 2 })
1563        .prepare()
1564  
1565      // @ts-expect-error - plan.bindings is not declared in types
1566      assert.equal(plan.bindings.get($.q), 2)
1567    },
1568  
1569    'unification in nested predicate propagates': async (assert) => {
1570      const plan = Analyzer.rule({
1571        match: { name: $.userName, this: $.user },
1572        when: {
1573          where: [
1574            { match: { the: 'user/name', of: $.userAccount, is: $.userName } },
1575            {
1576              match: { this: $.userAccount, as: $.user },
1577              rule: { match: { this: $.this, as: $.this }, when: {} },
1578            },
1579          ],
1580        },
1581      })
1582        .apply()
1583        .prepare()
1584  
1585      assert.ok(plan)
1586    },
1587  
1588    'should fail to plan if not bound': async (assert) => {
1589      const Person = Analyzer.rule({
1590        match: { name: $.name, this: $.this },
1591        when: {
1592          where: [{ match: { the: 'person/name', of: $.this, is: $.name } }],
1593        },
1594      })
1595  
1596      assert.throws(
1597        () => {
1598          Analyzer.rule({
1599            match: { name: $.name, this: $.this },
1600            when: {
1601              where: [{ match: { this: $.this, name: 'Ben' }, rule: Person }],
1602            },
1603          })
1604            .apply()
1605            .prepare()
1606        },
1607        /does not bind variable \?name/g,
1608        '$.name is not bound'
1609      )
1610    },
1611    'should be able to unify': async (assert) => {
1612      const plan = Analyzer.rule({
1613        match: { this: $.this, count: $.count },
1614        when: {
1615          where: [
1616            {
1617              not: {
1618                match: { this: $.this },
1619                rule: {
1620                  match: { this: $.this, count: $.count, title: $.title },
1621                  when: {
1622                    where: [
1623                      {
1624                        match: { the: 'counter/count', of: $.this, is: $.count },
1625                      },
1626                      {
1627                        match: { the: 'counter/title', of: $.this, is: $.title },
1628                      },
1629                    ],
1630                  },
1631                },
1632              },
1633            },
1634            {
1635              match: { the: 'counter/count', of: $.this, is: $.count },
1636            },
1637            {
1638              match: { the: 'counter/title', of: $.this, is: $.title },
1639            },
1640          ],
1641        },
1642      })
1643        .apply()
1644        .prepare()
1645  
1646      assert.ok(plan)
1647    },
1648  
1649    'skip recur with unbound variable should fail': async (assert) => {
1650      assert.throws(() => {
1651        Analyzer.rule({
1652          match: { name: $.name, extra: $.extra },
1653          when: {
1654            where: [
1655              { match: { the: 'person/name', is: $.name }, fact: {} },
1656              { recur: { name: $.name, extra: $.extra } },
1657            ],
1658          },
1659        })
1660          .apply()
1661          .prepare()
1662      }, /Unbound \?extra variable referenced from { recur/)
1663    },
1664  }