diff --git a/frontend/src/app/annotate/annotation-menu/annotation-menu.component.html b/frontend/src/app/annotate/annotation-menu/annotation-menu.component.html index dbea7eb0..820687f9 100644 --- a/frontend/src/app/annotate/annotation-menu/annotation-menu.component.html +++ b/frontend/src/app/annotate/annotation-menu/annotation-menu.component.html @@ -1,35 +1,54 @@ - - -
+@let proofs = proofs$ | async; + + + +
diff --git a/frontend/src/app/annotate/annotation-menu/annotation-menu.component.ts b/frontend/src/app/annotate/annotation-menu/annotation-menu.component.ts index f0c63cc3..1fdb4855 100644 --- a/frontend/src/app/annotate/annotation-menu/annotation-menu.component.ts +++ b/frontend/src/app/annotate/annotation-menu/annotation-menu.component.ts @@ -1,32 +1,37 @@ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { NgbNavModule } from "@ng-bootstrap/ng-bootstrap"; import { faSquarePollHorizontal, - faTree, faPenNib, } from "@fortawesome/free-solid-svg-icons"; + +import { ParseService } from "@/services/parse.service"; import { AnnotationParseResultsComponent } from "../annotation-parse-results/annotation-parse-results.component"; -import { AnnotationTableauComponent } from "../annotation-tableau/annotation-tableau.component"; +import { TableauSVG } from '../tableau-svg/tableau-svg.component'; import { AnnotationCommentsComponent } from "../annotation-comments/annotation-comments.component"; @Component({ selector: "la-annotation-menu", standalone: true, imports: [ + CommonModule, NgbNavModule, FontAwesomeModule, AnnotationParseResultsComponent, - AnnotationTableauComponent, + TableauSVG, AnnotationCommentsComponent, ], templateUrl: "./annotation-menu.component.html", styleUrl: "./annotation-menu.component.scss", }) export class AnnotationMenuComponent { + private parseService = inject(ParseService); + public readonly proofs$ = this.parseService.proofs$; + public active = 1; public faSquarePollHorizontal = faSquarePollHorizontal; - public faTree = faTree; public faPenNib = faPenNib; } diff --git a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.html b/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.html deleted file mode 100644 index 17691c03..00000000 --- a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.spec.ts b/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.spec.ts deleted file mode 100644 index 45773c4c..00000000 --- a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { AnnotationTableauComponent } from "./annotation-tableau.component"; - -describe("AnnotationTableauComponent", () => { - let component: AnnotationTableauComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AnnotationTableauComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AnnotationTableauComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.ts b/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.ts deleted file mode 100644 index 453539a2..00000000 --- a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from "@angular/core"; -import { TableauSVG } from "../tableau-svg/tableau-svg.component"; - -@Component({ - selector: "la-annotation-tableau", - standalone: true, - imports: [TableauSVG], - templateUrl: "./annotation-tableau.component.html", - styleUrl: "./annotation-tableau.component.scss", -}) -export class AnnotationTableauComponent {} diff --git a/frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.scss b/frontend/src/app/annotate/tableau-svg/tableau-node.component.scss similarity index 100% rename from frontend/src/app/annotate/annotation-tableau/annotation-tableau.component.scss rename to frontend/src/app/annotate/tableau-svg/tableau-node.component.scss diff --git a/frontend/src/app/annotate/tableau-svg/tableau-node.component.svg b/frontend/src/app/annotate/tableau-svg/tableau-node.component.svg new file mode 100644 index 00000000..c6c9e03a --- /dev/null +++ b/frontend/src/app/annotate/tableau-svg/tableau-node.component.svg @@ -0,0 +1,33 @@ + +@if(id) { + + {{ id }} +} + +@if(mod) { + + {{ mod }} +} + +@if(sign != null) { + +} + +@if(end) { + + {{rule ? '⛌' : '⭘'}} +} +@else { + {{ head }} +} + +@if(args) { + + {{ args }} +} + +@if(rule || end) { +{{ rule || 'Open branch' }} +} + + diff --git a/frontend/src/app/annotate/tableau-svg/tableau-node.component.ts b/frontend/src/app/annotate/tableau-svg/tableau-node.component.ts new file mode 100644 index 00000000..d0ef0462 --- /dev/null +++ b/frontend/src/app/annotate/tableau-svg/tableau-node.component.ts @@ -0,0 +1,84 @@ +import { Component, ElementRef, Input, Output, ViewChild, EventEmitter } from '@angular/core'; +import { Dimensions } from '@/types'; + +@Component({ + selector: "[node]", + standalone: true, + imports: [], + templateUrl: "./tableau-node.component.svg", + styleUrl: "./tableau-node.component.scss", +}) +export class TableauNode { + @Input() + public id?: number; + + @Input() + public mod?: string; + + @Input() + public args?: string; + + @Input() + public sign?: boolean; + + @Input() + public head: string = ''; + + @Input() + public end: boolean = false; + + @Input() + public rule?: string; + + @ViewChild('idText') + idText?: ElementRef; + + @ViewChild('headText') + headText?: ElementRef; + + @ViewChild('modText') + modText?: ElementRef; + + @ViewChild('argsText') + argsText?: ElementRef; + + @ViewChild('ruleText') + ruleText?: ElementRef; + + @Output() + public onSize = new EventEmitter(); + + padding = 5; + height = 20; + + idW = 0; + modX = 0; + modW = 0; + headX = 0; + headW = 0; + argsX = 0; + argsW = 0; + totalW = 0; + + ngAfterViewChecked() { + this.idW = this.idText ? this.idText.nativeElement.getComputedTextLength() + this.padding : 0; + this.modX = this.idW; + this.modW = this.modText ? this.modText.nativeElement.getComputedTextLength() + this.padding : 0; + this.argsW = this.argsText ? this.argsText.nativeElement.getComputedTextLength() + this.padding : 0; + this.headW = this.headText ? this.headText.nativeElement.getComputedTextLength() : 0; + this.headX = this.modX + this.modW; + this.argsX = this.headX + this.headW + this.padding; + + const mainW = ( + this.headText ? + this.argsW + this.idW + this.headW + this.modW : + 0 + ); + + const ruleW = this.ruleText ? this.ruleText.nativeElement.getComputedTextLength() : 0; + this.totalW = Math.max(mainW, ruleW) + + this.onSize.emit({width: this.totalW, height: this.height}); + } + +} diff --git a/frontend/src/app/annotate/tableau-svg/tableau-svg.component.svg b/frontend/src/app/annotate/tableau-svg/tableau-svg.component.svg index 5f91428d..f262c9eb 100644 --- a/frontend/src/app/annotate/tableau-svg/tableau-svg.component.svg +++ b/frontend/src/app/annotate/tableau-svg/tableau-svg.component.svg @@ -1,7 +1,8 @@ +@let proofTableau = tree(); - diff --git a/frontend/src/app/annotate/tableau-svg/tableau-svg.component.ts b/frontend/src/app/annotate/tableau-svg/tableau-svg.component.ts index 8a84fb32..e5addf95 100644 --- a/frontend/src/app/annotate/tableau-svg/tableau-svg.component.ts +++ b/frontend/src/app/annotate/tableau-svg/tableau-svg.component.ts @@ -1,7 +1,7 @@ -import { Component, ChangeDetectorRef} from '@angular/core'; +import { Component, ChangeDetectorRef, ElementRef, input } from '@angular/core'; import { CommonModule } from "@angular/common"; import { Subject } from "rxjs"; -import { Dimensions } from '@/types'; +import { Dimensions, ProofTree } from '@/types'; import { TableauTree } from './tableau-tree.component'; @Component({ @@ -13,10 +13,15 @@ import { TableauTree } from './tableau-tree.component'; }) export class TableauSVG { + public readonly tree = input.required(); + treeDimensions$ = new Subject(); treeDimensions: Dimensions = {width:0, height: 0}; - constructor(private cdref: ChangeDetectorRef) {} + constructor( + private cdref: ChangeDetectorRef, + private element: ElementRef + ) {} onTreeSize(size: Dimensions) { this.treeDimensions = size; @@ -25,59 +30,9 @@ export class TableauSVG { ngAfterViewChecked() { this.treeDimensions$.next(this.treeDimensions); this.cdref.detectChanges(); - } - - tree: any = { - nodes: [ - {idx: 1, term: 'every@man@(be@work)', bg: 'honeydew'}, - {idx: 2, term: 'every@(who@(be@work)@person)@(λ1,a@(expensive@car)@(λ2,have@2@1))', bg: 'honeydew'}, - {idx: 3, term: 'every@man@(λ3,a@car@(λ2,own@2@3))', bg: 'mistyrose'}, - {idx: 4, term: 'be@work', label: 'c1', rule: 'up_mon_fun_some[1,3]', bg: 'honeydew'}, - {idx: 5, term: '(λ3,a@car@(λ2,own@2@3))', label: 'c1', rule: 'up_mon_fun_some[1,3]', bg: 'mistyrose'}, - {idx: 6, term: 'man', label: 'c1', rule: 'up_mon_fun_some[1,3]', bg: 'honeydew'}, - {idx: 7, term: 'a@car@(λ2,own@2@c1)', rule: 'pull_arg[5]', bg: 'mistyrose'}, - {idx: 8, term: 'work', label: 'c1', rule: 'aux_verb[4]', bg: 'honeydew'}, - {idx: 9, term: 'be@work', label: 'c1', rule: 'tr_every_c[1,6], [c1]', bg: 'honeydew'}, - {idx: 8, term: 'work', label: 'c1', rule: 'aux_verb[9]', bg: 'honeydew'}, - ], - subtrees: [ - { - nodes: [ - {idx: 10, term: 'who@(be@work)@person', label: 'c1', rule: 'tr_every[2],[c1]', bg: 'mistyrose'}, - ], - subtrees: [ - { - nodes: [ - {idx: 13, term: 'be@work', label: 'c1', rule: 'fl_conj_who[10]', bg: 'mistyrose'}, - {idx: 16, term: 'work', label: 'c1', rule: 'aux_verb[13]', bg: 'mistyrose'}, - {end: true, rule: 'cl_subcat[16,8]'} - ], - }, - { - nodes: [ - {idx: 14, term: 'person', label: 'c1', rule: 'fl_conj_who[10]', bg: 'mistyrose'}, - {end: true, rule: 'cl_subsumption[14,6]'} - ] - } - ] - }, - { - nodes: [ - {idx: 11, term: 'who@(be@work)@person', label: 'c1', rule: 'tr_every[2],[c1]', bg: 'honeydew'}, - {idx: 12, term: '(λ1,a@(expensive@car)@(λ2,have@2@1))', label: 'c1', rule: 'tr_every[2],[c1]', bg: 'honeydew'}, - {idx: 15, term: 'a@(expensive@car)@(λ2,have@2@c1)', label: 'c1', rule: 'pull_arg[12]', bg: 'honeydew'}, - {idx: 17, term: 'be@work', label: 'c1', rule: 'tr_conj_who[11]', bg: 'honeydew'}, - {idx: 18, term: 'person', label: 'c1', rule: 'tr_conj_who[11]' , bg: 'honeydew'}, - {idx: 8, term: 'work', label: 'c1', rule: 'aux_verb[17]', bg: 'honeydew'}, - {idx: 19, term: 'a@(expensive@car)', label: '(λ2,have@2@c1)', rule: 'same_args[15,7]', bg: 'honeydew'}, - {idx: 20, term: 'a@car', label: '(λ2,have@2@c1)', rule: 'same_args[15,7]', bg: 'mistyrose'}, - {idx: 21, term: 'expensive@car', label: 'c2', rule: 'up_mon_fun[19,20]', bg: 'honeydew'}, - {idx: 22, term: 'car', label: 'c2', rule: 'up_mon_fun[19,20]', bg: 'mistyrose'}, - {idx: 23, term: 'car', label: 'c2', rule:'int_mod_tr[21]', bg: 'honeydew'}, - {idx: 24, term: 'expensive', label: 'c2', rule:'int_mod_tr[21]', bg: 'honeydew'}, - {end: true, rule: 'cl_subsmption[23,22]'} - ] - } - ] + const width = this.treeDimensions.width; + if (width) this.element.nativeElement.parentElement.scroll({ + left: Math.max(0, width / 2 - 500) + }); } } diff --git a/frontend/src/app/annotate/tableau-svg/tableau-term.component.scss b/frontend/src/app/annotate/tableau-svg/tableau-term.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/app/annotate/tableau-svg/tableau-term.component.svg b/frontend/src/app/annotate/tableau-svg/tableau-term.component.svg deleted file mode 100644 index 0f92a960..00000000 --- a/frontend/src/app/annotate/tableau-svg/tableau-term.component.svg +++ /dev/null @@ -1,28 +0,0 @@ - -@if(idx) { - - {{ idx }} -} - -@if(bg) { - -} - -@if(end) { - - -} -@else { - {{ term }} -} - -@if(label) { - - {{ label }} -} - -@if(rule) { -{{ rule }} -} - - diff --git a/frontend/src/app/annotate/tableau-svg/tableau-term.component.ts b/frontend/src/app/annotate/tableau-svg/tableau-term.component.ts deleted file mode 100644 index 616a0ba2..00000000 --- a/frontend/src/app/annotate/tableau-svg/tableau-term.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Component, ElementRef, Input, Output, ViewChild, EventEmitter } from '@angular/core'; -import { Dimensions } from '@/types'; - -@Component({ - selector: "[term]", - standalone: true, - imports: [], - templateUrl: "./tableau-term.component.svg", - styleUrl: "./tableau-term.component.scss", -}) -export class TableauTerm { - @Input() - public idx?: number; - - @Input() - public label?: string; - - /* background color, should probably be replaced by an enum type with the color lookup done elsewhere */ - @Input() - public bg?: string; - - @Input() - public term: string = ''; - - @Input() - public end: boolean = false; - - @Input() - public rule?: string; - - @ViewChild('idxText') - idxText?: ElementRef; - - @ViewChild('termText') - termText?: ElementRef; - - @ViewChild('labelText') - labelText?: ElementRef; - - @Output() - public onSize = new EventEmitter(); - - padding = 5; - height = 20; - - idxW = 0; - termX = 0; - termW = 0; - labelX = 0; - labelW = 0; - totalW = 0; - - ngAfterViewChecked() { - this.idxW = this.idxText ? this.idxText.nativeElement.getComputedTextLength() + this.padding : 0; - this.labelW = this.labelText ? this.labelText.nativeElement.getComputedTextLength() + this.padding : 0; - this.termX = this.idxW; - this.termW = this.termText ? this.termText.nativeElement.getComputedTextLength() : 0; - this.labelX = this.termX + this.termW + this.padding; - this.totalW = this.termText ? this.labelW + this.idxW + this.termText.nativeElement.getComputedTextLength() : 0; - - this.onSize.emit({width: this.totalW, height: this.height}); - } - -} diff --git a/frontend/src/app/annotate/tableau-svg/tableau-tree.component.svg b/frontend/src/app/annotate/tableau-svg/tableau-tree.component.svg index 1aea2b34..0a60239c 100644 --- a/frontend/src/app/annotate/tableau-svg/tableau-tree.component.svg +++ b/frontend/src/app/annotate/tableau-svg/tableau-tree.component.svg @@ -1,13 +1,14 @@ @for (node of visibleNodes(); track $index; let i = $index) { - @if(!expanded[$index]) { + + (click)="nodeClick($index)">+ } @else if (!$last) { diff --git a/frontend/src/app/annotate/tableau-svg/tableau-tree.component.ts b/frontend/src/app/annotate/tableau-svg/tableau-tree.component.ts index e2e7ef7b..ba8ca18c 100644 --- a/frontend/src/app/annotate/tableau-svg/tableau-tree.component.ts +++ b/frontend/src/app/annotate/tableau-svg/tableau-tree.component.ts @@ -1,32 +1,32 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { TableauTerm } from './tableau-term.component'; -import { Dimensions } from '@/types'; +import { TableauNode } from './tableau-node.component'; +import { Dimensions, ProofTree, ProofNode } from '@/types'; import { sum } from "@/util"; @Component({ selector: "[tableau-tree]", standalone: true, - imports: [TableauTerm], + imports: [TableauNode], templateUrl: "./tableau-tree.component.svg", styleUrl: "./tableau-tree.component.scss", }) export class TableauTree { expanded: boolean[] = []; - _tree: any; + _tree: ProofTree = {nodes: []}; @Input() - get tree(): any { + get tree(): ProofTree { return this._tree; } - set tree(value: any) { + set tree(value: ProofTree) { this._tree = value; this.expanded = value.nodes.map(() => true); } levelHeight = 40; - /* Width of the tree node, has to be determined dynamically via onSize events from terms */ + /* Width of the tree node, has to be determined dynamically via onSize events from nodes */ width = 0; /* Height isn't stored in a member variable, because it can be computed as needed */ @@ -61,7 +61,8 @@ export class TableauTree { /* Determines the X coordinate of where subtree of index `idx` should be drawn */ subtreePosition(idx: number) { let widthWithPadding = 1.15 * this.subWidth; - return widthWithPadding * idx - (widthWithPadding / 2) * (this.tree.subtrees.length - 1); + const subtreeCount = this.tree.subtrees!.length; + return widthWithPadding * idx - (widthWithPadding / 2) * (subtreeCount - 1); } /* Generates a path definition for linking the end of the current node to subtree index `idx` */ @@ -76,7 +77,7 @@ export class TableauTree { ].join(' '); } - nodeHeight(node: any) { + nodeHeight(node: ProofNode) { // note that in this case height includes bottom padding for the node return node.rule ? 60 : 40; } @@ -90,7 +91,7 @@ export class TableauTree { return sum(this.tree.nodes.slice(0, idx).map(this.nodeHeight)); } - termClick(idx: number) { + nodeClick(idx: number) { // the last node can't be collapsed // unless there are subtrees if (idx < this.expanded.length - 1 || this.tree.subtrees) { diff --git a/frontend/src/app/services/parse.service.spec.ts b/frontend/src/app/services/parse.service.spec.ts index de5486b0..b96aa6e4 100644 --- a/frontend/src/app/services/parse.service.spec.ts +++ b/frontend/src/app/services/parse.service.spec.ts @@ -1,8 +1,12 @@ import { TestBed } from '@angular/core/testing'; -import { ParseService } from './parse.service'; import { provideHttpClient } from '@angular/common/http'; +import { NLTKTree, ProofTree } from '@/types'; +import { ParseService, nltk2tableau } from './parse.service'; +import realNltk from '@/shared/mockNltkProof'; +import realTableau from '@/shared/mockTableauTree'; + describe('ParseService', () => { let service: ParseService; @@ -17,3 +21,115 @@ describe('ParseService', () => { expect(service).toBeTruthy(); }); }); + +describe('nltk2tableau', () => { + it('handles a trivial dead end', () => { + const nltk: NLTKTree = {node: 'Model'}; + const tableau: ProofTree = {nodes: [{end: true}]}; + expect(nltk2tableau(nltk)).toEqual(tableau); + }); + + it('handles a closing end', () => { + const nltk: NLTKTree = {node: 'Closed\ncl_subsumption([1, 2])'}; + const tableau: ProofTree = {nodes: [{ + end: true, + rule: 'cl_subsumption([1, 2])', + }]}; + expect(nltk2tableau(nltk)).toEqual(tableau); + }); + + it('handles nested nodes', () => { + const nltk: NLTKTree = { + node: '1\nterm\nTrue', + children: [{ + node: '2:some_rule([1])\nanotherterm\n[c1]\nFalse', + children: [{ + node: '3:another_rule([2])\n[c1]\nterm\nTrue', + children: [{ + node: '4:rule([1])\n[c2]\nterm\n[term]\nTrue', + children: [{ + node: 'Model', + }], + }], + }], + }], + }; + const tableau: ProofTree = { + nodes: [{ + id: 1, + head: 'term', + sign: true, + }, { + id: 2, + rule: 'some_rule([1])', + head: 'anotherterm', + args: 'c1', + sign: false, + }, { + id: 3, + rule: 'another_rule([2])', + mod: 'c1', + head: 'term', + sign: true, + }, { + id: 4, + rule: 'rule([1])', + mod: 'c2', + head: 'term', + args: 'term', + sign: true, + }, { + end: true, + }], + }; + expect(nltk2tableau(nltk)).toEqual(tableau); + }); + + it('can handle branching', () => { + const nltk: NLTKTree = { + node: '1:rule([1, 2])\nterm\n[arg]\nFalse', + children: [{ + node: 'Model', + }, { + node: 'Closed\nclosing_rule([1])', + }], + }; + const tableau: ProofTree = { + nodes: [{ + id: 1, + rule: 'rule([1, 2])', + head: 'term', + args: 'arg', + sign: false, + }], + subtrees: [{ + nodes: [{end: true}], + }, { + nodes: [{end: true, rule: 'closing_rule([1])'}], + }], + }; + expect(nltk2tableau(nltk)).toEqual(tableau); + }); + + it('does not cause a stack overflow', () => { + let nltk: NLTKTree = {node: 'Model'}; + const tableau: ProofTree = {nodes: []}; + for (let i = 0; i < 100000; ++i) { + nltk = { + node: '1\nterm\nFalse', + children: [nltk], + }; + tableau.nodes.push({ + id: 1, + head: 'term', + sign: false, + }); + } + tableau.nodes.push({end: true}); + expect(nltk2tableau(nltk)).toEqual(tableau); + }); + + it('can process real-world data', () => { + expect(nltk2tableau(realNltk)).toEqual(realTableau); + }); +}); diff --git a/frontend/src/app/services/parse.service.ts b/frontend/src/app/services/parse.service.ts index bbf3a5da..6d250f31 100644 --- a/frontend/src/app/services/parse.service.ts +++ b/frontend/src/app/services/parse.service.ts @@ -1,16 +1,16 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { Subject, switchMap, catchError, of, merge, map } from 'rxjs'; +import { Subject, switchMap, catchError, of, merge, map, share } from 'rxjs'; import { ParseInput } from '@/annotate/annotation-input/annotation-input.component'; import { ProblemService } from './problem.service'; -import { ParseResponseData } from '@/types'; +import { + ParseResponse, + NLTKTree, + ProofNode, + ProofTree, +} from '@/types'; -export type ParseResponse = { - data: ParseResponseData; - error: string | null; -}; - @Injectable({ providedIn: 'root' }) @@ -18,11 +18,13 @@ export class ParseService { private http = inject(HttpClient); private problemService = inject(ProblemService); + // Sink for triggering parse-and-prove requests to the backend. public submit$ = new Subject(); // Clear parse results when a new problem is loaded. private clearOnNewProblem$ = this.problemService.problemResponse$.pipe(map(() => null)); + // Raw responses from the backend. private parseResults$ = this.submit$.pipe( switchMap((form) => this.http.post("/api/problem/parse", form).pipe( @@ -31,11 +33,153 @@ export class ParseService { return of({ data: null, error: error.message || "An error occurred while parsing the problem." }); }), ) - ) + ), + share() ); public parse$ = merge( this.parseResults$, this.clearOnNewProblem$ ); + + // Proofs from the response in a format that is easier to handle than the + // stringly NLTK.Tree serialization. + public proofs$ = this.parse$.pipe(map(extractProofs)); +} + +/** + * Given a `{data: {ccg_trees, proofs}}` response from the backend, tease out + * the `proofs` and apply the `nltk2tableau` transformation to each proof + * within. + */ +function extractProofs( + response: ParseResponse | {data: null, error: any} | null +) { + if (!response || !response.data) return null; + // This is basically just `_.mapObject(response.data.proofs, nltk2tableau)`, + // but we don't depend on Underscore yet. + const {entailment, contradiction} = response.data.proofs; + return { + entailment: nltk2tableau(entailment), + contradiction: nltk2tableau(contradiction), + }; +} + +// Regular expressions matching the stringified nltk.Tree node payloads. +const openLeafPattern = /^Model$/; +const closedLeafPattern = /^Closed\n(?.+)$/; +const internalNodePattern = /^(?\d+):?(?.+)?(\n\[(?.+)\])?\n(?.+)(\n\[(?.+)\])?\n(?True|False)$/; + +// Helpers for the functions below. +const emptyTableau = (): ProofTree => ({nodes: []}); +const pairNodes = (other: any[]) => + (node: any, index: number) => [node, other[index]]; +const quotes = /'/g; +const unquote = (text: string) => text.replace(quotes, ''); + +type ProofConversionThunk = [NLTKTree, ProofTree]; + +/** + * Given a single proof in serialized NLTK.Tree format, return the same proof in + * a more convenient format where each node has explicitly labeled `id`, `rule`, + * `mod`, `head`, `args` and `sign`. + * + * This function can handle proof trees of arbitrary depth without causing a + * stack overflow. + */ +export function nltk2tableau(nltk: NLTKTree): ProofTree { + const tree = emptyTableau(); + // We will be using trampolining instead of recursion. A general but + // admittedly not very helpful description can be found in the first bullet + // of + // https://en.wikipedia.org/wiki/Trampoline_(computing)#High-level_programming. + // The gist is this: instead of recursing, functions return pieces of work + // ("thunks") that should be executed next. The trampoline, which in this + // case is the current function, takes care of executing the thunks. + + // The list of thunks to execute. One will be added for each tree node, one + // will be taken off on each iteration. Since we are always calling + // `nltkNode2tableauNode`, we leave this implicit in the thunk + // representation. It consists only of the arguments that we are passing to + // the function. + const todo: ProofConversionThunk[] = [[nltk, tree]]; + // The core of the trampoline. Process one thunk at a time. We know we are + // done when the list is empty. + while (todo.length) { + // The algorithm is written such that the order in which we process + // child nodes does not matter, so we can simply pop thunks off the back + // of the todo list. tsc is apparently unable to infer that `todo` is + // guaranteed to be nonempty on the next line; hence + // the type assertion. + const task = todo.pop() as ProofConversionThunk; + // Processing a thunk might produce zero or more new thunks, depending + // on the number of children of the processed node. + const newTasks = nltkNode2tableauNode(...task); + todo.push(...newTasks); + } + return tree; +} + +/** + * Internal function that maps a single serialized NLTK.Tree node to an + * explicitly structured node inside `tree`. This function relies on an external + * trampoline (in `nltk2tableau`) and returns thunks instead of recursing into + * child nodes. + */ +function nltkNode2tableauNode(nltk: NLTKTree, tree: ProofTree): +ProofConversionThunk[] { + // `nltk` is in a strictly nested format: each node contains its children. + // Our own format does not follow this rule: chains of nodes with single + // children are stored as adjacent elements in an array. `tree` is the + // object that contains this array (`tree.nodes`). When there is a + // bifurcation, the subtrees are stored inside `tree` rather than in the + // bifurcating node. + const node = parseNltkNode(nltk.node); + tree.nodes.push(node); + // With the node itself having been decoded, now comes the part where a + // "normal" function would recurse and where we return follow-up thunks + // instead. + if (!nltk.children || !nltk.children.length) { + node.end = true; + // Leaf node. No recursion, no thunks. + return []; + } + if (nltk.children.length === 1) { + // Node with one child, the most common case. The child will be appended + // to `tree.nodes` after the current node. + return [[nltk.children[0], tree]]; + } + // Multiple children. Most likely two, but the code would transparently + // support larger numbers of children. We create empty subtrees so we can + // pass those in the thunks as containers for the child nodes. The fact that + // the subtrees have already been created, is what makes it possible to + // process the children in arbitrary order. + tree.subtrees = nltk.children.map(emptyTableau); + // We essentially do a `_.zip(nltk.children, tree.subtrees)` in order to + // create the thunks. I could have fumbled with `Iterator.zip` instead, but + // that feels like more hassle and the polyfill would be huge. + return nltk.children.map(pairNodes(tree.subtrees)) as ProofConversionThunk[]; +} + +/** + * Decode the payload of a serialized nltk.Tree node, where all information is + * combined in a single string, back to an object with explicitly labeled parts. + */ +function parseNltkNode(text: string): ProofNode { + if (openLeafPattern.test(text)) return {}; + let match; + if (match = text.match(closedLeafPattern)) { + return match.groups as unknown as ProofNode; + } + match = text.match(internalNodePattern); + if (!match || !match.groups) return {head: text}; + const result: any = { + id: +match.groups['id'], + head: match.groups['head'], + sign: match.groups['sign'] === 'True', + }; + if (match.groups['rule']) result.rule = unquote(match.groups['rule']); + if (match.groups['mod']) result.mod = match.groups['mod']; + if (match.groups['args']) result.args = match.groups['args']; + return result; } diff --git a/frontend/src/app/shared/mockNltkProof.ts b/frontend/src/app/shared/mockNltkProof.ts new file mode 100644 index 00000000..ecd66424 --- /dev/null +++ b/frontend/src/app/shared/mockNltkProof.ts @@ -0,0 +1,133 @@ +import { NLTKTree } from '@/types'; + +const tree: NLTKTree = { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "node": "Closed\ncl_subsumption([15, 8])" + } + ], + "node": "15:empty_mod([12])\nwork\n[c1]\nFalse" + } + ], + "node": "12:fl_conj_who([9])\nbe work\n[c1]\nFalse" + }, + { + "children": [ + { + "node": "Closed\ncl_subsumption([13, 6])" + } + ], + "node": "13:fl_conj_who([9])\nperson\n[c1]\nFalse" + } + ], + "node": "9:tr_every([2],['c1'])\n(who (be work)) person\n[c1]\nFalse" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "node": "Closed\ncl_subsumption([22, 21])" + } + ], + "node": "23:int_mod_tr([20])\nexpensive\n[c2]\nTrue" + } + ], + "node": "22:int_mod_tr([20])\ncar\n[c2]\nTrue" + } + ], + "node": "21:up_mon_fun(['c2'],[18,19])\ncar\n[c2]\nFalse" + } + ], + "node": "20:up_mon_fun(['c2'],[18,19])\nexpensive car\n[c2]\nTrue" + } + ], + "node": "19:same_args_tf([14,7])\na car\n[λE.((have E) c1)]\nFalse" + } + ], + "node": "18:same_args_tf([14,7])\na (expensive car)\n[λE.((have E) c1)]\nTrue" + } + ], + "node": "8:empty_mod([16])\nwork\n[c1]\nTrue" + } + ], + "node": "17:tr_conj_who([10])\nperson\n[c1]\nTrue" + } + ], + "node": "16:tr_conj_who([10])\nbe work\n[c1]\nTrue" + } + ], + "node": "14:pull_arg([11])\n(a (expensive car))(λE.((have E) c1))\nTrue" + } + ], + "node": "11:tr_every([2],['c1'])\nλF.((a (expensive car))(λE.((have E) F)))\n[c1]\nTrue" + } + ], + "node": "10:tr_every([2],['c1'])\n(who (be work)) person\n[c1]\nTrue" + } + ], + "node": "8:empty_mod([4])\nwork\n[c1]\nTrue" + } + ], + "node": "7:pull_arg([5])\n(a car)(λE.((own E) c1))\nFalse" + } + ], + "node": "6:up_mon_fun_some(['c1'],[1,3])\nman\n[c1]\nTrue" + } + ], + "node": "5:up_mon_fun_some(['c1'],[1,3])\nλM.((a car)(λE.((own E) M)))\n[c1]\nFalse" + } + ], + "node": "4:up_mon_fun_some(['c1'],[1,3])\nbe work\n[c1]\nTrue" + } + ], + "node": "3:\n(every man)(λM.((a car)(λE.((own E) M))))\nFalse" + } + ], + "node": "2:\n(every ((who (be work)) person))(λF.((a (expensive car))(λE.((have E) F))))\nTrue" + } + ], + "node": "1:\n(every man)(be work)\nTrue" +}; + +export default tree; diff --git a/frontend/src/app/shared/mockTableauTree.ts b/frontend/src/app/shared/mockTableauTree.ts new file mode 100644 index 00000000..aac896c3 --- /dev/null +++ b/frontend/src/app/shared/mockTableauTree.ts @@ -0,0 +1,55 @@ +import { ProofTree } from '@/types'; + +const tree: ProofTree = { + nodes: [ + {id: 1, head: '(every man)(be work)', sign: true}, + {id: 2, head: '(every ((who (be work)) person))(λF.((a (expensive car))(λE.((have E) F))))', sign: true}, + {id: 3, head: '(every man)(λM.((a car)(λE.((own E) M))))', sign: false}, + {id: 4, head: 'be work', args: 'c1', rule: 'up_mon_fun_some([c1],[1,3])', sign: true}, + {id: 5, head: 'λM.((a car)(λE.((own E) M)))', args: 'c1', rule: 'up_mon_fun_some([c1],[1,3])', sign: false}, + {id: 6, head: 'man', args: 'c1', rule: 'up_mon_fun_some([c1],[1,3])', sign: true}, + {id: 7, head: '(a car)(λE.((own E) c1))', rule: 'pull_arg([5])', sign: false}, + {id: 8, head: 'work', args: 'c1', rule: 'empty_mod([4])', sign: true}, + ], + subtrees: [ + { + nodes: [ + {id: 9, head: '(who (be work)) person', args: 'c1', rule: 'tr_every([2],[c1])', sign: false}, + ], + subtrees: [ + { + nodes: [ + {id: 12, head: 'be work', args: 'c1', rule: 'fl_conj_who([9])', sign: false}, + {id: 15, head: 'work', args: 'c1', rule: 'empty_mod([12])', sign: false}, + {end: true, rule: 'cl_subsumption([15, 8])'} + ], + }, + { + nodes: [ + {id: 13, head: 'person', args: 'c1', rule: 'fl_conj_who([9])', sign: false}, + {end: true, rule: 'cl_subsumption([13, 6])'} + ] + } + ] + }, + { + nodes: [ + {id: 10, head: '(who (be work)) person', args: 'c1', rule: 'tr_every([2],[c1])', sign: true}, + {id: 11, head: 'λF.((a (expensive car))(λE.((have E) F)))', args: 'c1', rule: 'tr_every([2],[c1])', sign: true}, + {id: 14, head: '(a (expensive car))(λE.((have E) c1))', rule: 'pull_arg([11])', sign: true}, + {id: 16, head: 'be work', args: 'c1', rule: 'tr_conj_who([10])', sign: true}, + {id: 17, head: 'person', args: 'c1', rule: 'tr_conj_who([10])' , sign: true}, + {id: 8, head: 'work', args: 'c1', rule: 'empty_mod([16])', sign: true}, + {id: 18, head: 'a (expensive car)', args: 'λE.((have E) c1)', rule: 'same_args_tf([14,7])', sign: true}, + {id: 19, head: 'a car', args: 'λE.((have E) c1)', rule: 'same_args_tf([14,7])', sign: false}, + {id: 20, head: 'expensive car', args: 'c2', rule: 'up_mon_fun([c2],[18,19])', sign: true}, + {id: 21, head: 'car', args: 'c2', rule: 'up_mon_fun([c2],[18,19])', sign: false}, + {id: 22, head: 'car', args: 'c2', rule: 'int_mod_tr([20])', sign: true}, + {id: 23, head: 'expensive', args: 'c2', rule: 'int_mod_tr([20])', sign: true}, + {end: true, rule: 'cl_subsumption([22, 21])'} + ] + } + ] +}; + +export default tree; diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts index 9ffd6c00..f097e28d 100644 --- a/frontend/src/app/types.ts +++ b/frontend/src/app/types.ts @@ -133,7 +133,7 @@ export interface Dimensions { } // -// Syntactic parse tree types +// Syntactic parse and proof tree types from the backend // export type LeafNode = { @@ -160,8 +160,39 @@ export interface CCGParse { ccg_trees: Record; }; +export interface NLTKTree { + node: string; + children?: NLTKTree[]; +} + export type ParseResponseData = { ccg_parses: CCGParse[]; - proofs: unknown[]; + proofs: { + entailment: NLTKTree, + contradiction: NLTKTree, + }; +}; + +export type ParseResponse = { + data: ParseResponseData; + error: string | null; }; +// +// Our internal representation of proof trees +// + +export type ProofNode = { + id?: number; + rule?: string; + mod?: string; + head?: string; + args?: string; + sign?: boolean; + end?: true; +}; + +export interface ProofTree { + nodes: ProofNode[]; + subtrees?: ProofTree[]; +}