Andrej Janchevski commited on
Commit
cd5136e
·
1 Parent(s): f31c85c

feat(coins): add interactive query builder and reasoning dashboard

Browse files

Full COINs demo at /demos/coins with query structure picker (7 templates),
searchable entity/relation dropdowns with server-side pagination, algorithm
selector filtered by structure+dataset compatibility, SVG query graph
visualization with slot-based editing, and a results dashboard showing
ranked predictions, community rank callout, and timing breakdown with
speedup estimate.

- Pinia store manages the full query lifecycle (registry load, slot
filling, predict call, error handling for 429/422/503)
- QueryGraph uses CSS Grid with ResizeObserver-driven edge routing
- SearchablePicker debounces 250ms with AbortController cancellation
- Route /demos/coins lazy-loaded, nav link + DemoPreviewCard wired

src/frontend/src/api/coins.js CHANGED
@@ -1,5 +1,24 @@
1
  import { client } from './client'
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  export async function sampleTriples(datasetId, count = 1, seed = null) {
4
  const params = { count }
5
  if (seed != null) params.seed = seed
@@ -7,7 +26,24 @@ export async function sampleTriples(datasetId, count = 1, seed = null) {
7
  return res.data
8
  }
9
 
10
- export async function listDatasets() {
11
- const res = await client.get('/coins/datasets')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  return res.data
13
  }
 
1
  import { client } from './client'
2
 
3
+ export async function listDatasets() {
4
+ const res = await client.get('/coins/datasets')
5
+ return res.data
6
+ }
7
+
8
+ export async function searchEntities(datasetId, { q = '', page = 1, pageSize = 50, signal } = {}) {
9
+ const params = { page, page_size: pageSize }
10
+ if (q) params.q = q
11
+ const res = await client.get(`/coins/datasets/${datasetId}/entities`, { params, signal })
12
+ return res.data
13
+ }
14
+
15
+ export async function searchRelations(datasetId, { q = '', page = 1, pageSize = 50, signal } = {}) {
16
+ const params = { page, page_size: pageSize }
17
+ if (q) params.q = q
18
+ const res = await client.get(`/coins/datasets/${datasetId}/relations`, { params, signal })
19
+ return res.data
20
+ }
21
+
22
  export async function sampleTriples(datasetId, count = 1, seed = null) {
23
  const params = { count }
24
  if (seed != null) params.seed = seed
 
26
  return res.data
27
  }
28
 
29
+ export async function sampleQuery(datasetId, queryStructure, count = 1, seed = null) {
30
+ const params = { query_structure: queryStructure, count }
31
+ if (seed != null) params.seed = seed
32
+ const res = await client.get(`/coins/datasets/${datasetId}/sample-query`, { params })
33
+ return res.data
34
+ }
35
+
36
+ export async function listModels() {
37
+ const res = await client.get('/coins/models')
38
+ return res.data
39
+ }
40
+
41
+ export async function listQueryStructures() {
42
+ const res = await client.get('/coins/query-structures')
43
+ return res.data
44
+ }
45
+
46
+ export async function predict(payload, { signal } = {}) {
47
+ const res = await client.post('/coins/predict', payload, { signal, timeout: 60000 })
48
  return res.data
49
  }
src/frontend/src/components/coins/AlgorithmSelector.vue ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ defineProps({
3
+ algorithms: { type: Array, required: true },
4
+ modelValue: { type: String, default: '' },
5
+ disabled: { type: Boolean, default: false },
6
+ })
7
+ const emit = defineEmits(['update:modelValue'])
8
+ function change(e) { emit('update:modelValue', e.target.value) }
9
+ </script>
10
+
11
+ <template>
12
+ <div class="algo-selector">
13
+ <label class="algo-label">Algorithm</label>
14
+ <select :value="modelValue" @change="change" class="algo-select" :disabled="disabled || !algorithms.length">
15
+ <option v-if="!algorithms.length" value="">No algorithm for this pair</option>
16
+ <option v-for="m in algorithms" :key="m.algorithm" :value="m.algorithm" :title="m.description">
17
+ {{ m.name }}
18
+ </option>
19
+ </select>
20
+ </div>
21
+ </template>
22
+
23
+ <style scoped>
24
+ .algo-selector { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; width: 100%; }
25
+ .algo-label {
26
+ font-size: 0.8em;
27
+ color: var(--text-muted);
28
+ text-transform: uppercase;
29
+ letter-spacing: 0.04em;
30
+ line-height: 1.2;
31
+ min-height: 1.2em;
32
+ }
33
+ .algo-select {
34
+ height: 2.4rem;
35
+ padding: 0 0.6rem;
36
+ border: 1px solid var(--border);
37
+ border-radius: 0.4rem;
38
+ background: var(--surface);
39
+ color: var(--text);
40
+ font: inherit;
41
+ box-sizing: border-box;
42
+ width: 100%;
43
+ }
44
+ .algo-select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
45
+ </style>
src/frontend/src/components/coins/CommunityRankCallout.vue ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ defineProps({
3
+ rankC: { type: Number, required: true },
4
+ })
5
+ </script>
6
+
7
+ <template>
8
+ <div class="community-callout" :class="{ miss: rankC === 0 }">
9
+ <div class="cc-icon"><i :class="['icon', rankC === 0 ? 'times circle' : 'map marker alternate']"></i></div>
10
+ <div class="cc-body">
11
+ <div class="cc-head">
12
+ <span v-if="rankC > 0">Community rank <strong>#{{ rankC }}</strong></span>
13
+ <span v-else>No valid answers found</span>
14
+ </div>
15
+ <div class="cc-sub">
16
+ <template v-if="rankC === 1">
17
+ The best-scoring community contained a valid answer — COINs localized the search immediately.
18
+ </template>
19
+ <template v-else-if="rankC > 1">
20
+ COINs had to scan {{ rankC }} communities before hitting a valid answer. Step 2 still only
21
+ scored candidates inside those communities, not the whole graph.
22
+ </template>
23
+ <template v-else>
24
+ The KG contains no answer matching this query. Try a different anchor, relation, or pin
25
+ different variables.
26
+ </template>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </template>
31
+
32
+ <style scoped>
33
+ .community-callout {
34
+ display: flex;
35
+ gap: 0.75rem;
36
+ padding: 0.85rem 1rem;
37
+ background: var(--primary-soft);
38
+ border-radius: 0.5rem;
39
+ border-left: 3px solid var(--primary);
40
+ }
41
+ .community-callout.miss {
42
+ background: rgba(192, 57, 43, 0.08);
43
+ border-left-color: #c0392b;
44
+ }
45
+ .cc-icon i.icon { font-size: 1.8em; color: var(--primary); margin: 0; }
46
+ .community-callout.miss .cc-icon i.icon { color: #c0392b; }
47
+ .cc-head { font-size: 1.05em; font-weight: 600; margin-bottom: 0.2rem; }
48
+ .cc-sub { font-size: 0.9em; color: var(--text-muted); }
49
+ </style>
src/frontend/src/components/coins/PredictionList.vue ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ defineProps({
3
+ predictions: { type: Array, required: true },
4
+ })
5
+
6
+ function barWidth(score) {
7
+ const pct = Math.max(0, Math.min(1, score)) * 100
8
+ return pct.toFixed(1) + '%'
9
+ }
10
+ </script>
11
+
12
+ <template>
13
+ <div class="pred-list">
14
+ <h3 class="ui header"><i class="list ol icon"></i>Top-K Predictions</h3>
15
+ <div v-if="!predictions.length" class="muted">No predictions returned.</div>
16
+ <div v-else class="pred-rows">
17
+ <div v-for="p in predictions" :key="p.entity_id" class="pred-row">
18
+ <div class="pred-rank" :title="`intra-community rank ${p.intra_community_rank}`">
19
+ #{{ p.rank }}
20
+ </div>
21
+ <div class="pred-body">
22
+ <div class="pred-name" :title="`entity id ${p.entity_id}`">{{ p.entity_name }}</div>
23
+ <div class="pred-bar">
24
+ <div class="pred-bar-fill" :style="{ width: barWidth(p.score) }"></div>
25
+ </div>
26
+ <div class="pred-meta">
27
+ <span class="pred-score">score {{ p.score.toFixed(4) }}</span>
28
+ <span class="muted">intra-community #{{ p.intra_community_rank }}</span>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .pred-list { padding: 0; }
38
+ .pred-rows { display: flex; flex-direction: column; gap: 0.5rem; }
39
+ .pred-row {
40
+ display: grid;
41
+ grid-template-columns: 3.5rem 1fr;
42
+ gap: 0.75rem;
43
+ align-items: center;
44
+ padding: 0.6rem;
45
+ background: var(--surface);
46
+ border: 1px solid var(--border);
47
+ border-radius: 0.5rem;
48
+ }
49
+ .pred-rank {
50
+ font-family: ui-monospace, SFMono-Regular, monospace;
51
+ font-weight: 700;
52
+ color: var(--primary-strong);
53
+ text-align: center;
54
+ font-size: 1.1em;
55
+ }
56
+ .pred-body { min-width: 0; }
57
+ .pred-name { font-weight: 600; word-break: break-word; }
58
+ .pred-bar {
59
+ width: 100%;
60
+ height: 6px;
61
+ background: var(--surface-muted);
62
+ border-radius: 3px;
63
+ overflow: hidden;
64
+ margin: 0.35rem 0 0.25rem;
65
+ }
66
+ .pred-bar-fill { height: 100%; background: var(--primary); }
67
+ .pred-meta { display: flex; gap: 0.75rem; font-size: 0.8em; }
68
+ .pred-score { color: var(--text); font-family: ui-monospace, SFMono-Regular, monospace; }
69
+ .muted { color: var(--text-muted); }
70
+ </style>
src/frontend/src/components/coins/QueryDescription.vue ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ defineProps({
3
+ description: { type: String, required: true },
4
+ structure: { type: String, default: '' },
5
+ algorithm: { type: String, default: '' },
6
+ datasetId: { type: String, default: '' },
7
+ })
8
+ </script>
9
+
10
+ <template>
11
+ <div class="query-desc">
12
+ <div class="qd-chips">
13
+ <span v-if="datasetId" class="ui tiny label"><i class="database icon"></i>{{ datasetId }}</span>
14
+ <span v-if="structure" class="ui tiny label">{{ structure }}</span>
15
+ <span v-if="algorithm" class="ui tiny green label">{{ algorithm }}</span>
16
+ </div>
17
+ <p class="qd-text">{{ description }}</p>
18
+ </div>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .query-desc {
23
+ padding: 0.75rem 1rem;
24
+ background: var(--primary-soft);
25
+ border-left: 3px solid var(--primary);
26
+ border-radius: 0.4rem;
27
+ margin-bottom: 1rem;
28
+ }
29
+ .qd-chips { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.35rem; }
30
+ .qd-text { margin: 0; font-weight: 500; word-break: break-word; }
31
+ </style>
src/frontend/src/components/coins/QueryGraph.vue ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
3
+
4
+ const props = defineProps({
5
+ structure: { type: Object, required: true },
6
+ mode: { type: String, default: 'edit' },
7
+ cellMinWidth: { type: Number, default: 200 },
8
+ cellMinHeight: { type: Number, default: 110 },
9
+ colGap: { type: Number, default: 72 },
10
+ rowGap: { type: Number, default: 56 },
11
+ })
12
+
13
+ const TARGET_ID = 't'
14
+
15
+ const EDIT_LAYOUTS = {
16
+ '1p': {
17
+ cols: 2, rows: 1,
18
+ cells: [
19
+ { id: 'a', col: 1, row: 1 },
20
+ { id: 't', col: 2, row: 1 },
21
+ ],
22
+ },
23
+ '2p': {
24
+ cols: 3, rows: 1,
25
+ cells: [
26
+ { id: 'a', col: 1, row: 1 },
27
+ { id: 'v1', col: 2, row: 1 },
28
+ { id: 't', col: 3, row: 1 },
29
+ ],
30
+ },
31
+ '3p': {
32
+ cols: 4, rows: 1,
33
+ cells: [
34
+ { id: 'a', col: 1, row: 1 },
35
+ { id: 'v1', col: 2, row: 1 },
36
+ { id: 'v2', col: 3, row: 1 },
37
+ { id: 't', col: 4, row: 1 },
38
+ ],
39
+ },
40
+ '2i': {
41
+ cols: 2, rows: 2,
42
+ cells: [
43
+ { id: 'a1', col: 1, row: 1 },
44
+ { id: 'a2', col: 1, row: 2 },
45
+ { id: 't', col: 2, row: 1, rowSpan: 2 },
46
+ ],
47
+ },
48
+ '3i': {
49
+ cols: 2, rows: 3,
50
+ cells: [
51
+ { id: 'a1', col: 1, row: 1 },
52
+ { id: 'a2', col: 1, row: 2 },
53
+ { id: 'a3', col: 1, row: 3 },
54
+ { id: 't', col: 2, row: 1, rowSpan: 3 },
55
+ ],
56
+ },
57
+ 'ip': {
58
+ cols: 3, rows: 2,
59
+ cells: [
60
+ { id: 'a1', col: 1, row: 1 },
61
+ { id: 'a2', col: 1, row: 2 },
62
+ { id: 'v1', col: 2, row: 1, rowSpan: 2 },
63
+ { id: 't', col: 3, row: 1, rowSpan: 2 },
64
+ ],
65
+ },
66
+ 'pi': {
67
+ cols: 3, rows: 2,
68
+ cells: [
69
+ { id: 'a1', col: 1, row: 1 },
70
+ { id: 'v1', col: 2, row: 1 },
71
+ { id: 'a2', col: 1, row: 2 },
72
+ { id: 't', col: 3, row: 1, rowSpan: 2 },
73
+ ],
74
+ },
75
+ }
76
+
77
+ // Preview reuses the 2D edit layouts: multi-row intersection queries (2i/3i/ip/pi)
78
+ // route converging edges as straight diagonals instead of stacked arcs, so relation
79
+ // labels don't pile on top of each other and the grid's bounding box already
80
+ // contains every edge — no need to expand the SVG viewBox beyond it.
81
+ const PREVIEW_LAYOUTS = EDIT_LAYOUTS
82
+
83
+ const allNodes = computed(() => {
84
+ const base = props.structure.nodes.slice()
85
+ if (!base.some((n) => n.id === TARGET_ID)) {
86
+ base.push({ id: TARGET_ID, type: 'target', label: '?' })
87
+ }
88
+ return base
89
+ })
90
+
91
+ const nodeById = computed(() => {
92
+ const m = {}
93
+ for (const n of allNodes.value) m[n.id] = n
94
+ return m
95
+ })
96
+
97
+ const layout = computed(() => {
98
+ const map = props.mode === 'preview' ? PREVIEW_LAYOUTS : EDIT_LAYOUTS
99
+ return map[props.structure.id] || { cols: 1, rows: 1, cells: [] }
100
+ })
101
+
102
+ const cellCol = computed(() => {
103
+ const m = {}
104
+ for (const c of layout.value.cells) m[c.id] = c.col
105
+ return m
106
+ })
107
+
108
+ const dynamicColGap = ref(props.colGap)
109
+ const dynamicRowGap = ref(props.rowGap)
110
+
111
+ const gridStyle = computed(() => ({
112
+ 'grid-template-columns': `repeat(${layout.value.cols}, minmax(min-content, 1fr))`,
113
+ 'grid-template-rows': `repeat(${layout.value.rows}, minmax(${props.cellMinHeight}px, auto))`,
114
+ 'column-gap': dynamicColGap.value + 'px',
115
+ 'row-gap': dynamicRowGap.value + 'px',
116
+ }))
117
+
118
+ function cellStyle(cell) {
119
+ const colEnd = cell.colSpan ? cell.col + cell.colSpan : cell.col + 1
120
+ const rowEnd = cell.rowSpan ? cell.row + cell.rowSpan : cell.row + 1
121
+ return {
122
+ 'grid-column': `${cell.col} / ${colEnd}`,
123
+ 'grid-row': `${cell.row} / ${rowEnd}`,
124
+ }
125
+ }
126
+
127
+ const gridEl = ref(null)
128
+ const nodeRefs = reactive({})
129
+ function setNodeRef(id, el) {
130
+ if (el) nodeRefs[id] = el
131
+ else delete nodeRefs[id]
132
+ }
133
+ const edgeRefs = reactive({})
134
+ function setEdgeRef(id, el) {
135
+ if (el) edgeRefs[id] = el
136
+ else delete edgeRefs[id]
137
+ }
138
+
139
+ const geometry = ref({ w: 0, h: 0, centers: {}, rects: {} })
140
+
141
+ // The grid cell is much larger than its content pill (minmax(Npx, auto)),
142
+ // so measuring the cell gives the line endpoints a rect that overshoots the
143
+ // visible pill. Measure the cell's first child (the slot-node or fallback pill)
144
+ // instead — it hugs the pill, so intersectRect lands right at the pill edge.
145
+ function contentOf(cellEl) {
146
+ return cellEl?.firstElementChild || cellEl
147
+ }
148
+
149
+ function measure() {
150
+ if (!gridEl.value) return
151
+ let maxNodeW = 0
152
+ let maxNodeH = 0
153
+ for (const id of Object.keys(nodeRefs)) {
154
+ const el = contentOf(nodeRefs[id])
155
+ if (!el) continue
156
+ const r = el.getBoundingClientRect()
157
+ if (r.width > maxNodeW) maxNodeW = r.width
158
+ if (r.height > maxNodeH) maxNodeH = r.height
159
+ }
160
+ let maxEdgeW = 0
161
+ let maxEdgeH = 0
162
+ for (const id of Object.keys(edgeRefs)) {
163
+ const el = edgeRefs[id]
164
+ if (!el) continue
165
+ const r = el.getBoundingClientRect()
166
+ if (r.width > maxEdgeW) maxEdgeW = r.width
167
+ if (r.height > maxEdgeH) maxEdgeH = r.height
168
+ }
169
+ const colGapFromEdge = maxEdgeW + 48
170
+ const colGapFromNodeOverlap = Math.ceil(maxNodeW * 0.15) + 24
171
+ let gap = Math.max(props.colGap, colGapFromEdge, colGapFromNodeOverlap)
172
+ const numGaps = Math.max(1, layout.value.cols - 1)
173
+ const containerW = gridEl.value.parentElement?.clientWidth || gridEl.value.clientWidth
174
+ const maxGap = Math.floor((containerW - layout.value.cols * 80) / numGaps)
175
+ if (maxGap > 0 && gap > maxGap) gap = Math.max(props.colGap, maxGap)
176
+ dynamicColGap.value = gap
177
+ dynamicRowGap.value = Math.max(props.rowGap, maxEdgeH + 28)
178
+
179
+ const gridRect = gridEl.value.getBoundingClientRect()
180
+ const centers = {}
181
+ const rects = {}
182
+ for (const id of Object.keys(nodeRefs)) {
183
+ const el = contentOf(nodeRefs[id])
184
+ if (!el) continue
185
+ const r = el.getBoundingClientRect()
186
+ const x = r.left - gridRect.left + r.width / 2
187
+ const y = r.top - gridRect.top + r.height / 2
188
+ centers[id] = { x, y }
189
+ rects[id] = { w: r.width, h: r.height }
190
+ }
191
+ geometry.value = { w: gridRect.width, h: gridRect.height, centers, rects }
192
+ }
193
+
194
+ function intersectRect(cx, cy, w, h, tx, ty) {
195
+ const dx = tx - cx
196
+ const dy = ty - cy
197
+ if (dx === 0 && dy === 0) return { x: cx, y: cy }
198
+ const hw = w / 2
199
+ const hh = h / 2
200
+ const sx = dx === 0 ? Infinity : Math.abs(hw / dx)
201
+ const sy = dy === 0 ? Infinity : Math.abs(hh / dy)
202
+ const t = Math.min(sx, sy)
203
+ return { x: cx + dx * t, y: cy + dy * t }
204
+ }
205
+
206
+ const edgeRoutes = computed(() => {
207
+ const { centers, rects } = geometry.value
208
+ const cols = cellCol.value
209
+ return (props.structure.edges || []).map((e) => {
210
+ const s = centers[e.source]
211
+ const t = centers[e.target]
212
+ if (!s || !t) return null
213
+ const sr = rects[e.source] || { w: 0, h: 0 }
214
+ const tr = rects[e.target] || { w: 0, h: 0 }
215
+ const p1 = intersectRect(s.x, s.y, sr.w - 4, sr.h - 4, t.x, t.y)
216
+ const p2 = intersectRect(t.x, t.y, tr.w - 4, tr.h - 4, s.x, s.y)
217
+
218
+ const colSpan = Math.abs((cols[e.target] || 0) - (cols[e.source] || 0))
219
+ const sameRow = Math.abs(s.y - t.y) < 8
220
+ const needsCurve = colSpan > 1 && sameRow
221
+ let path
222
+ let mx = (p1.x + p2.x) / 2
223
+ let my = (p1.y + p2.y) / 2
224
+ if (needsCurve) {
225
+ const arc = Math.min(70, 22 + colSpan * 14)
226
+ const cy = my - arc
227
+ path = `M ${p1.x} ${p1.y} Q ${mx} ${cy} ${p2.x} ${p2.y}`
228
+ my = my - arc / 2
229
+ } else {
230
+ path = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`
231
+ }
232
+ return { ...e, path, mx, my }
233
+ }).filter(Boolean)
234
+ })
235
+
236
+ let ro = null
237
+ let ticking = false
238
+ function schedule() {
239
+ if (ticking) return
240
+ ticking = true
241
+ requestAnimationFrame(() => {
242
+ ticking = false
243
+ measure()
244
+ })
245
+ }
246
+
247
+ onMounted(async () => {
248
+ await nextTick()
249
+ measure()
250
+ ro = new ResizeObserver(schedule)
251
+ if (gridEl.value) ro.observe(gridEl.value)
252
+ for (const id of Object.keys(nodeRefs)) {
253
+ if (nodeRefs[id]) ro.observe(nodeRefs[id])
254
+ }
255
+ for (const id of Object.keys(edgeRefs)) {
256
+ if (edgeRefs[id]) ro.observe(edgeRefs[id])
257
+ }
258
+ window.addEventListener('resize', schedule)
259
+ if (document.fonts?.ready) document.fonts.ready.then(schedule)
260
+ })
261
+
262
+ onBeforeUnmount(() => {
263
+ if (ro) ro.disconnect()
264
+ window.removeEventListener('resize', schedule)
265
+ })
266
+
267
+ watch(() => props.structure?.id, async () => {
268
+ await nextTick()
269
+ schedule()
270
+ })
271
+
272
+ watch(nodeRefs, () => {
273
+ if (!ro) return
274
+ for (const id of Object.keys(nodeRefs)) {
275
+ const el = nodeRefs[id]
276
+ if (el) ro.observe(el)
277
+ }
278
+ schedule()
279
+ }, { deep: true })
280
+
281
+ watch(edgeRefs, () => {
282
+ if (!ro) return
283
+ for (const id of Object.keys(edgeRefs)) {
284
+ const el = edgeRefs[id]
285
+ if (el) ro.observe(el)
286
+ }
287
+ schedule()
288
+ }, { deep: true })
289
+
290
+ function edgeLabelStyle(e) {
291
+ return { left: e.mx + 'px', top: e.my + 'px' }
292
+ }
293
+ </script>
294
+
295
+ <template>
296
+ <div class="query-graph" :class="{ preview: mode === 'preview' }">
297
+ <div class="qg-grid" ref="gridEl" :style="gridStyle">
298
+ <svg class="qg-edges"
299
+ :viewBox="`0 0 ${geometry.w || 1} ${geometry.h || 1}`"
300
+ :width="geometry.w || 0" :height="geometry.h || 0"
301
+ aria-hidden="true"
302
+ preserveAspectRatio="none">
303
+ <defs>
304
+ <marker id="qg-arrow" viewBox="0 0 10 10" refX="9" refY="5"
305
+ markerWidth="7" markerHeight="7" orient="auto-start-reverse">
306
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--primary)" />
307
+ </marker>
308
+ </defs>
309
+ <path v-for="e in edgeRoutes" :key="'e-' + e.id"
310
+ :d="e.path"
311
+ class="qg-edge" marker-end="url(#qg-arrow)" />
312
+ </svg>
313
+
314
+ <div v-for="cell in layout.cells" :key="'c-' + cell.id"
315
+ :ref="(el) => setNodeRef(cell.id, el)"
316
+ class="qg-cell"
317
+ :class="['qg-' + (nodeById[cell.id]?.type || 'target')]"
318
+ :style="cellStyle(cell)">
319
+ <slot :name="'node-' + cell.id" :node="nodeById[cell.id]">
320
+ <span class="qg-fallback" :class="'qg-fallback-' + (nodeById[cell.id]?.type || 'target')">
321
+ {{ nodeById[cell.id]?.label || cell.id }}
322
+ </span>
323
+ </slot>
324
+ </div>
325
+
326
+ <div v-for="e in edgeRoutes" :key="'el-' + e.id"
327
+ :ref="(el) => setEdgeRef(e.id, el)"
328
+ class="qg-edge-label"
329
+ :style="edgeLabelStyle(e)">
330
+ <slot :name="'edge-' + e.id" :edge="e">
331
+ <span class="qg-fallback qg-fallback-edge">{{ e.label || e.id }}</span>
332
+ </slot>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ </template>
337
+
338
+ <style scoped>
339
+ .query-graph {
340
+ position: relative;
341
+ width: 100%;
342
+ display: flex;
343
+ justify-content: center;
344
+ padding: 1rem 0;
345
+ }
346
+ .qg-grid {
347
+ position: relative;
348
+ display: grid;
349
+ justify-items: center;
350
+ align-items: center;
351
+ width: 100%;
352
+ isolation: isolate;
353
+ }
354
+ .qg-cell {
355
+ position: relative;
356
+ z-index: 2;
357
+ display: inline-flex;
358
+ align-items: center;
359
+ justify-content: center;
360
+ }
361
+ .qg-cell:focus-within,
362
+ .qg-cell:hover {
363
+ z-index: 20;
364
+ }
365
+ .qg-edges {
366
+ position: absolute;
367
+ inset: 0;
368
+ pointer-events: none;
369
+ z-index: 1;
370
+ overflow: visible;
371
+ }
372
+ .qg-edge {
373
+ stroke: var(--primary);
374
+ stroke-width: 2;
375
+ fill: none;
376
+ opacity: 0.8;
377
+ }
378
+ .qg-edge-label {
379
+ position: absolute;
380
+ transform: translate(-50%, -50%);
381
+ z-index: 3;
382
+ background: var(--surface);
383
+ border-radius: 999px;
384
+ padding: 0.15rem 0.35rem;
385
+ }
386
+ .qg-edge-label:focus-within,
387
+ .qg-edge-label:hover {
388
+ z-index: 21;
389
+ }
390
+ .qg-fallback {
391
+ display: inline-block;
392
+ padding: 0.4em 0.8em;
393
+ border-radius: 999px;
394
+ background: var(--primary-soft);
395
+ color: var(--text);
396
+ border: 1px solid var(--primary);
397
+ font-size: 0.9em;
398
+ max-width: min(40ch, 90vw);
399
+ word-break: break-word;
400
+ }
401
+ .qg-fallback-target {
402
+ background: var(--surface);
403
+ border: 2px dashed var(--primary);
404
+ font-weight: 700;
405
+ }
406
+ .qg-fallback-variable {
407
+ background: var(--surface-muted);
408
+ border-style: dashed;
409
+ }
410
+ .qg-fallback-edge {
411
+ background: var(--surface);
412
+ border: 1px solid var(--border);
413
+ color: var(--text-muted);
414
+ font-size: 0.8em;
415
+ padding: 0.2em 0.6em;
416
+ }
417
+
418
+ .query-graph.preview { padding: 0; }
419
+ .query-graph.preview .qg-edge { stroke-width: 1.5; }
420
+ .query-graph.preview .qg-fallback {
421
+ padding: 0.25em 0.55em;
422
+ font-size: 0.7em;
423
+ max-width: 10ch;
424
+ white-space: nowrap;
425
+ overflow: hidden;
426
+ text-overflow: ellipsis;
427
+ }
428
+ </style>
src/frontend/src/components/coins/QueryStructurePicker.vue ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { ref, computed, watch } from 'vue'
3
+ import QueryGraph from './QueryGraph.vue'
4
+
5
+ const props = defineProps({
6
+ structures: { type: Array, required: true },
7
+ modelValue: { type: String, default: '' },
8
+ })
9
+ const emit = defineEmits(['update:modelValue'])
10
+
11
+ const index = ref(0)
12
+
13
+ watch(
14
+ () => [props.modelValue, props.structures.length],
15
+ () => {
16
+ const i = props.structures.findIndex((s) => s.id === props.modelValue)
17
+ if (i >= 0) index.value = i
18
+ },
19
+ { immediate: true },
20
+ )
21
+
22
+ const current = computed(() => props.structures[index.value] || null)
23
+ const count = computed(() => props.structures.length)
24
+
25
+ function go(delta) {
26
+ if (!count.value) return
27
+ const next = (index.value + delta + count.value) % count.value
28
+ index.value = next
29
+ const s = props.structures[next]
30
+ if (s) emit('update:modelValue', s.id)
31
+ }
32
+
33
+ function jump(i) {
34
+ if (i < 0 || i >= count.value) return
35
+ index.value = i
36
+ emit('update:modelValue', props.structures[i].id)
37
+ }
38
+
39
+ function select() {
40
+ if (current.value) emit('update:modelValue', current.value.id)
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div class="structure-carousel" v-if="current">
46
+ <button type="button" class="nav-btn" @click="go(-1)" aria-label="Previous structure"
47
+ :disabled="count < 2">
48
+ <i class="chevron left icon"></i>
49
+ </button>
50
+
51
+ <button type="button" class="structure-card"
52
+ :class="{ active: modelValue === current.id }"
53
+ @click="select" :aria-pressed="modelValue === current.id">
54
+ <div class="structure-head">
55
+ <span class="structure-id">{{ current.id }}</span>
56
+ <span class="structure-name">{{ current.name }}</span>
57
+ </div>
58
+ <div class="structure-viz">
59
+ <QueryGraph :structure="current" mode="preview"
60
+ :cell-min-width="56" :cell-min-height="32"
61
+ :col-gap="72" :row-gap="28" />
62
+ </div>
63
+ <div class="structure-desc">{{ current.description }}</div>
64
+ </button>
65
+
66
+ <button type="button" class="nav-btn" @click="go(1)" aria-label="Next structure"
67
+ :disabled="count < 2">
68
+ <i class="chevron right icon"></i>
69
+ </button>
70
+ </div>
71
+
72
+ <div v-if="count > 1" class="dots">
73
+ <button v-for="(s, i) in structures" :key="s.id" type="button"
74
+ class="dot" :class="{ active: i === index }"
75
+ @click="jump(i)" :aria-label="`Jump to ${s.id}`">
76
+ <span>{{ s.id }}</span>
77
+ </button>
78
+ </div>
79
+ </template>
80
+
81
+ <style scoped>
82
+ .structure-carousel {
83
+ display: grid;
84
+ grid-template-columns: auto 1fr auto;
85
+ align-items: stretch;
86
+ gap: 0.5rem;
87
+ }
88
+ .nav-btn {
89
+ background: var(--surface);
90
+ border: 1px solid var(--border);
91
+ border-radius: 0.5rem;
92
+ color: var(--primary-strong);
93
+ cursor: pointer;
94
+ padding: 0 0.6rem;
95
+ font-size: 1.3rem;
96
+ transition: 0.15s;
97
+ }
98
+ .nav-btn:hover:enabled { background: var(--primary-soft); border-color: var(--primary); }
99
+ .nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
100
+ .nav-btn i.icon { margin: 0; }
101
+
102
+ .structure-card {
103
+ background: var(--surface);
104
+ border: 1px solid var(--border);
105
+ border-radius: 0.6rem;
106
+ padding: 0.5rem 0.5rem 0.6rem;
107
+ cursor: pointer;
108
+ text-align: center;
109
+ color: var(--text);
110
+ font: inherit;
111
+ transition: 0.15s;
112
+ box-shadow: var(--shadow-sm);
113
+ display: flex;
114
+ flex-direction: column;
115
+ align-items: center;
116
+ gap: 0.35rem;
117
+ min-height: 300px;
118
+ overflow: visible;
119
+ }
120
+ .structure-card:hover { border-color: var(--primary); transform: translateY(-1px); }
121
+ .structure-card.active {
122
+ border-color: var(--primary);
123
+ background: var(--primary-soft);
124
+ box-shadow: var(--shadow-md);
125
+ }
126
+ .structure-head {
127
+ display: flex;
128
+ align-items: baseline;
129
+ justify-content: center;
130
+ gap: 0.5rem;
131
+ flex-wrap: wrap;
132
+ }
133
+ .structure-id {
134
+ font-family: ui-monospace, SFMono-Regular, monospace;
135
+ font-weight: 700;
136
+ color: var(--primary-strong);
137
+ font-size: 1.2em;
138
+ }
139
+ .structure-name { font-weight: 600; font-size: 1em; }
140
+ .structure-viz {
141
+ min-height: 180px;
142
+ width: 100%;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ overflow: visible;
147
+ }
148
+ .structure-desc {
149
+ font-size: 0.85em;
150
+ color: var(--text-muted);
151
+ line-height: 1.4;
152
+ max-width: 48rem;
153
+ }
154
+
155
+ .dots {
156
+ display: flex;
157
+ gap: 0.35rem;
158
+ justify-content: center;
159
+ margin-top: 0.75rem;
160
+ flex-wrap: wrap;
161
+ }
162
+ .dot {
163
+ min-width: 2.6rem;
164
+ height: 1.8rem;
165
+ padding: 0 0.5rem;
166
+ border-radius: 999px;
167
+ border: 1px solid var(--border);
168
+ background: var(--surface);
169
+ color: var(--text-muted);
170
+ font: inherit;
171
+ font-size: 0.78em;
172
+ font-family: ui-monospace, SFMono-Regular, monospace;
173
+ font-weight: 600;
174
+ cursor: pointer;
175
+ transition: 0.15s;
176
+ }
177
+ .dot:hover { border-color: var(--primary); color: var(--primary-strong); }
178
+ .dot.active {
179
+ background: var(--primary);
180
+ color: #fff;
181
+ border-color: var(--primary);
182
+ }
183
+
184
+ </style>
src/frontend/src/components/coins/ResultsDashboard.vue ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import QueryDescription from './QueryDescription.vue'
3
+ import PredictionList from './PredictionList.vue'
4
+ import CommunityRankCallout from './CommunityRankCallout.vue'
5
+ import TimingPanel from './TimingPanel.vue'
6
+
7
+ defineProps({
8
+ result: { type: Object, required: true },
9
+ })
10
+ </script>
11
+
12
+ <template>
13
+ <div class="results-dashboard">
14
+ <QueryDescription
15
+ :description="result.query_description"
16
+ :structure="result.query_structure"
17
+ :algorithm="result.algorithm"
18
+ :dataset-id="result.dataset_id" />
19
+
20
+ <CommunityRankCallout :rank-c="result.timing?.rank_c ?? 0" />
21
+
22
+ <div class="dashboard-grid">
23
+ <div class="ui segment">
24
+ <PredictionList :predictions="result.predictions || []" />
25
+ </div>
26
+ <div class="ui segment">
27
+ <TimingPanel :timing="result.timing || {}" />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .results-dashboard { display: flex; flex-direction: column; gap: 1rem; }
35
+ .dashboard-grid {
36
+ display: grid;
37
+ grid-template-columns: 1fr 1fr;
38
+ gap: 1rem;
39
+ }
40
+ .ui.segment { margin: 0 !important; }
41
+ @media (max-width: 900px) {
42
+ .dashboard-grid { grid-template-columns: 1fr; }
43
+ }
44
+ </style>
src/frontend/src/components/coins/SearchableEntityDropdown.vue ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { searchEntities } from '../../api/coins'
4
+ import SearchablePicker from './SearchablePicker.vue'
5
+
6
+ const props = defineProps({
7
+ modelValue: { type: Object, default: null },
8
+ datasetId: { type: String, required: true },
9
+ placeholder: { type: String, default: 'Pick entity' },
10
+ disabled: { type: Boolean, default: false },
11
+ })
12
+ defineEmits(['update:modelValue'])
13
+
14
+ const fetcher = computed(() => {
15
+ const ds = props.datasetId
16
+ return (opts) => searchEntities(ds, opts)
17
+ })
18
+ </script>
19
+
20
+ <template>
21
+ <SearchablePicker
22
+ :model-value="modelValue"
23
+ :fetcher="fetcher"
24
+ kind="entity"
25
+ :placeholder="placeholder"
26
+ :disabled="disabled || !datasetId"
27
+ @update:modelValue="$emit('update:modelValue', $event)" />
28
+ </template>
src/frontend/src/components/coins/SearchablePicker.vue ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: { type: Object, default: null },
6
+ fetcher: { type: Function, required: true },
7
+ placeholder: { type: String, default: 'Pick…' },
8
+ kind: { type: String, default: 'entity' },
9
+ disabled: { type: Boolean, default: false },
10
+ pageSize: { type: Number, default: 25 },
11
+ })
12
+ const emit = defineEmits(['update:modelValue'])
13
+
14
+ const open = ref(false)
15
+ const query = ref('')
16
+ const items = ref([])
17
+ const total = ref(0)
18
+ const page = ref(1)
19
+ const loading = ref(false)
20
+ const errorMsg = ref('')
21
+ const highlight = ref(-1)
22
+ const rootEl = ref(null)
23
+ const inputEl = ref(null)
24
+
25
+ let abortCtl = null
26
+ let debounceTimer = null
27
+
28
+ const display = computed(() => {
29
+ if (!props.modelValue) return ''
30
+ return props.modelValue.label || props.modelValue.name || String(props.modelValue.id)
31
+ })
32
+
33
+ const hasMore = computed(() => items.value.length < total.value)
34
+
35
+ async function runFetch({ reset = false } = {}) {
36
+ if (abortCtl) abortCtl.abort()
37
+ abortCtl = new AbortController()
38
+ loading.value = true
39
+ errorMsg.value = ''
40
+ try {
41
+ const res = await props.fetcher({
42
+ q: query.value,
43
+ page: reset ? 1 : page.value,
44
+ pageSize: props.pageSize,
45
+ signal: abortCtl.signal,
46
+ })
47
+ const list = res?.entities || res?.relations || res?.results || []
48
+ total.value = res?.total ?? list.length
49
+ if (reset) {
50
+ items.value = list
51
+ page.value = 1
52
+ highlight.value = list.length ? 0 : -1
53
+ } else {
54
+ items.value = [...items.value, ...list]
55
+ }
56
+ } catch (e) {
57
+ if (e?.name === 'CanceledError' || e?.name === 'AbortError') return
58
+ errorMsg.value = e?.response?.data?.error?.message || e?.message || 'Lookup failed'
59
+ } finally {
60
+ loading.value = false
61
+ }
62
+ }
63
+
64
+ function scheduleFetch() {
65
+ if (debounceTimer) clearTimeout(debounceTimer)
66
+ debounceTimer = setTimeout(() => runFetch({ reset: true }), 250)
67
+ }
68
+
69
+ async function openMenu() {
70
+ if (props.disabled) return
71
+ open.value = true
72
+ await nextTick()
73
+ inputEl.value?.focus()
74
+ if (!items.value.length && !loading.value) runFetch({ reset: true })
75
+ }
76
+
77
+ function closeMenu() {
78
+ open.value = false
79
+ if (abortCtl) abortCtl.abort()
80
+ if (debounceTimer) clearTimeout(debounceTimer)
81
+ }
82
+
83
+ function onInput(e) {
84
+ query.value = e.target.value
85
+ scheduleFetch()
86
+ }
87
+
88
+ function pick(item) {
89
+ emit('update:modelValue', item)
90
+ closeMenu()
91
+ }
92
+
93
+ function clear() {
94
+ emit('update:modelValue', null)
95
+ }
96
+
97
+ async function loadMore() {
98
+ if (loading.value || !hasMore.value) return
99
+ page.value += 1
100
+ await runFetch({ reset: false })
101
+ }
102
+
103
+ function onKey(e) {
104
+ if (!open.value) return
105
+ if (e.key === 'Escape') { e.preventDefault(); closeMenu() }
106
+ else if (e.key === 'ArrowDown') { e.preventDefault(); highlight.value = Math.min(highlight.value + 1, items.value.length - 1) }
107
+ else if (e.key === 'ArrowUp') { e.preventDefault(); highlight.value = Math.max(highlight.value - 1, 0) }
108
+ else if (e.key === 'Enter') {
109
+ e.preventDefault()
110
+ if (items.value[highlight.value]) pick(items.value[highlight.value])
111
+ }
112
+ }
113
+
114
+ function onDocClick(e) {
115
+ if (!open.value) return
116
+ if (rootEl.value && !rootEl.value.contains(e.target)) closeMenu()
117
+ }
118
+ document.addEventListener('mousedown', onDocClick)
119
+
120
+ onBeforeUnmount(() => {
121
+ document.removeEventListener('mousedown', onDocClick)
122
+ if (abortCtl) abortCtl.abort()
123
+ if (debounceTimer) clearTimeout(debounceTimer)
124
+ })
125
+
126
+ watch(() => props.fetcher, () => {
127
+ items.value = []
128
+ total.value = 0
129
+ page.value = 1
130
+ if (open.value) runFetch({ reset: true })
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <div class="picker" :class="['picker-' + kind, { open, disabled }]" ref="rootEl">
136
+ <button type="button" class="picker-pill" :class="{ filled: !!modelValue }"
137
+ @click="open ? closeMenu() : openMenu()" :disabled="disabled"
138
+ :title="modelValue?.name || ''" :aria-haspopup="true" :aria-expanded="open">
139
+ <span class="pill-text">{{ display || placeholder }}</span>
140
+ <span v-if="modelValue" class="pill-clear" role="button"
141
+ aria-label="Clear selection"
142
+ @click.stop="clear">✕</span>
143
+ <i v-else class="icon-caret">▾</i>
144
+ </button>
145
+
146
+ <div v-if="open" class="picker-menu" @keydown="onKey">
147
+ <div class="picker-search">
148
+ <input ref="inputEl" type="text" :value="query" @input="onInput"
149
+ :placeholder="`Search ${kind}…`" @keydown="onKey" />
150
+ </div>
151
+ <div class="picker-list">
152
+ <div v-if="loading && !items.length" class="picker-info">Searching…</div>
153
+ <div v-else-if="errorMsg" class="picker-error">{{ errorMsg }}</div>
154
+ <div v-else-if="!items.length" class="picker-info">No matches.</div>
155
+ <button v-for="(it, i) in items" :key="it.id" type="button"
156
+ class="picker-item" :class="{ hi: i === highlight, picked: modelValue?.id === it.id }"
157
+ @mouseenter="highlight = i" @click="pick(it)">
158
+ <span class="item-label">{{ it.label || it.name || it.id }}</span>
159
+ <span v-if="it.label && it.name && it.label !== it.name" class="item-sub">{{ it.name }}</span>
160
+ </button>
161
+ <button v-if="hasMore" type="button" class="picker-more"
162
+ :disabled="loading" @click="loadMore">
163
+ {{ loading ? 'Loading…' : `Load more (${items.length} / ${total})` }}
164
+ </button>
165
+ <div v-else-if="items.length && !loading" class="picker-info">
166
+ {{ items.length }} of {{ total }}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </template>
172
+
173
+ <style scoped>
174
+ .picker { position: relative; display: inline-block; max-width: 100%; }
175
+ .picker.disabled { opacity: 0.6; pointer-events: none; }
176
+
177
+ .picker-pill {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ gap: 0.3rem;
181
+ width: max-content;
182
+ max-width: min(40ch, 90vw);
183
+ padding: 0.4em 0.75em;
184
+ border-radius: 999px;
185
+ border: 1px solid var(--primary);
186
+ background: var(--surface);
187
+ color: var(--text);
188
+ font: inherit;
189
+ font-size: 0.9em;
190
+ cursor: pointer;
191
+ white-space: normal;
192
+ overflow-wrap: anywhere;
193
+ text-align: left;
194
+ box-shadow: var(--shadow-sm);
195
+ transition: 0.15s;
196
+ }
197
+ .picker-pill:hover { background: var(--primary-soft); }
198
+ .picker-pill.filled {
199
+ background: var(--primary-soft);
200
+ font-weight: 500;
201
+ }
202
+ .picker-relation .picker-pill {
203
+ border-style: dashed;
204
+ background: var(--surface);
205
+ color: var(--text-muted);
206
+ font-size: 0.82em;
207
+ }
208
+ .picker-relation .picker-pill.filled {
209
+ color: var(--text);
210
+ border-style: solid;
211
+ background: var(--primary-soft);
212
+ }
213
+ .pill-text { flex: 1 1 auto; }
214
+ .pill-clear {
215
+ font-size: 0.8em;
216
+ opacity: 0.6;
217
+ padding: 0 0.25em;
218
+ cursor: pointer;
219
+ }
220
+ .pill-clear:hover { opacity: 1; color: var(--primary-strong); }
221
+ .icon-caret { font-size: 0.75em; opacity: 0.7; }
222
+
223
+ .picker-menu {
224
+ position: absolute;
225
+ top: calc(100% + 0.3rem);
226
+ left: 0;
227
+ z-index: 1000;
228
+ min-width: 18rem;
229
+ max-width: min(90vw, 32rem);
230
+ background: var(--surface);
231
+ border: 1px solid var(--border);
232
+ border-radius: 0.5rem;
233
+ box-shadow: var(--shadow-md);
234
+ overflow: hidden;
235
+ }
236
+ .picker-search { padding: 0.5rem; border-bottom: 1px solid var(--border); }
237
+ .picker-search input {
238
+ width: 100%;
239
+ border: 1px solid var(--border);
240
+ border-radius: 0.4rem;
241
+ padding: 0.35rem 0.5rem;
242
+ background: var(--surface);
243
+ color: var(--text);
244
+ }
245
+ .picker-search input:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
246
+ .picker-list {
247
+ max-height: 18rem;
248
+ overflow-y: auto;
249
+ padding: 0.25rem 0;
250
+ }
251
+ .picker-item {
252
+ display: flex;
253
+ flex-direction: column;
254
+ align-items: flex-start;
255
+ width: 100%;
256
+ background: none;
257
+ border: 0;
258
+ padding: 0.4rem 0.75rem;
259
+ text-align: left;
260
+ color: var(--text);
261
+ cursor: pointer;
262
+ font: inherit;
263
+ font-size: 0.9em;
264
+ }
265
+ .picker-item.hi, .picker-item:hover { background: var(--primary-soft); }
266
+ .picker-item.picked { font-weight: 600; color: var(--primary-strong); }
267
+ .item-label { word-break: break-word; }
268
+ .item-sub { font-size: 0.75em; color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, monospace; }
269
+ .picker-info { padding: 0.5rem 0.75rem; color: var(--text-muted); font-size: 0.85em; }
270
+ .picker-error { padding: 0.5rem 0.75rem; color: #c0392b; font-size: 0.85em; }
271
+ .picker-more {
272
+ width: 100%;
273
+ padding: 0.5rem;
274
+ border: 0;
275
+ border-top: 1px solid var(--border);
276
+ background: var(--surface-muted);
277
+ cursor: pointer;
278
+ font-size: 0.85em;
279
+ color: var(--primary-strong);
280
+ }
281
+ .picker-more:hover:enabled { background: var(--primary-soft); }
282
+ </style>
src/frontend/src/components/coins/SearchableRelationDropdown.vue ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { searchRelations } from '../../api/coins'
4
+ import SearchablePicker from './SearchablePicker.vue'
5
+
6
+ const props = defineProps({
7
+ modelValue: { type: Object, default: null },
8
+ datasetId: { type: String, required: true },
9
+ placeholder: { type: String, default: 'Pick relation' },
10
+ disabled: { type: Boolean, default: false },
11
+ })
12
+ defineEmits(['update:modelValue'])
13
+
14
+ const fetcher = computed(() => {
15
+ const ds = props.datasetId
16
+ return (opts) => searchRelations(ds, opts)
17
+ })
18
+ </script>
19
+
20
+ <template>
21
+ <SearchablePicker
22
+ :model-value="modelValue"
23
+ :fetcher="fetcher"
24
+ kind="relation"
25
+ :placeholder="placeholder"
26
+ :disabled="disabled || !datasetId"
27
+ @update:modelValue="$emit('update:modelValue', $event)" />
28
+ </template>
src/frontend/src/components/coins/TimingPanel.vue ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ timing: { type: Object, required: true },
6
+ })
7
+
8
+ const maxMs = computed(() => {
9
+ const t = props.timing
10
+ return Math.max(t.total_ms || 0, t.baseline_estimate_ms || 0, 1)
11
+ })
12
+
13
+ function pct(v) {
14
+ return Math.max(1, (v / maxMs.value) * 100) + '%'
15
+ }
16
+
17
+ function fmt(v) {
18
+ if (v == null) return '—'
19
+ return v >= 100 ? v.toFixed(0) + ' ms' : v.toFixed(1) + ' ms'
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div class="timing-panel">
25
+ <h3 class="ui header"><i class="stopwatch icon"></i>Timing &amp; Speedup</h3>
26
+
27
+ <div class="speedup-hero">
28
+ <span class="speedup-val">×{{ (timing.speedup || 0).toFixed(1) }}</span>
29
+ <span class="speedup-label">faster than full-graph inference (Prop. 3.1)</span>
30
+ </div>
31
+
32
+ <div class="bars">
33
+ <div class="bar-row">
34
+ <div class="bar-label">{{ timing.step1_label || 'Step 1' }}</div>
35
+ <div class="bar-track">
36
+ <div class="bar-fill step1" :style="{ width: pct(timing.step1_ms) }"></div>
37
+ </div>
38
+ <div class="bar-value">{{ fmt(timing.step1_ms) }}</div>
39
+ </div>
40
+
41
+ <div class="bar-row">
42
+ <div class="bar-label">{{ timing.step2_label || 'Step 2' }}</div>
43
+ <div class="bar-track">
44
+ <div class="bar-fill step2" :style="{ width: pct(timing.step2_ms) }"></div>
45
+ </div>
46
+ <div class="bar-value">{{ fmt(timing.step2_ms) }}</div>
47
+ </div>
48
+
49
+ <div class="bar-row total">
50
+ <div class="bar-label">COINs total</div>
51
+ <div class="bar-track">
52
+ <div class="bar-fill total" :style="{ width: pct(timing.total_ms) }"></div>
53
+ </div>
54
+ <div class="bar-value">{{ fmt(timing.total_ms) }}</div>
55
+ </div>
56
+
57
+ <div class="bar-row baseline">
58
+ <div class="bar-label">{{ timing.baseline_label || 'Baseline estimate' }}</div>
59
+ <div class="bar-track">
60
+ <div class="bar-fill baseline" :style="{ width: pct(timing.baseline_estimate_ms) }"></div>
61
+ </div>
62
+ <div class="bar-value">{{ fmt(timing.baseline_estimate_ms) }}</div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .timing-panel { padding: 0; }
70
+ .speedup-hero {
71
+ display: flex;
72
+ gap: 0.6rem;
73
+ align-items: baseline;
74
+ padding: 0.75rem 1rem;
75
+ background: var(--primary-soft);
76
+ border-radius: 0.5rem;
77
+ margin-bottom: 0.75rem;
78
+ }
79
+ .speedup-val { font-size: 2.2rem; font-weight: 700; color: var(--primary-strong); line-height: 1; }
80
+ .speedup-label { color: var(--text-muted); font-size: 0.9em; }
81
+
82
+ .bars { display: flex; flex-direction: column; gap: 0.4rem; }
83
+ .bar-row {
84
+ display: grid;
85
+ grid-template-columns: minmax(7rem, 14rem) 1fr minmax(4.5rem, auto);
86
+ gap: 0.6rem;
87
+ align-items: center;
88
+ font-size: 0.9em;
89
+ }
90
+ .bar-label { color: var(--text-muted); }
91
+ .bar-row.total .bar-label, .bar-row.total .bar-value { color: var(--text); font-weight: 600; }
92
+ .bar-track {
93
+ height: 12px;
94
+ background: var(--surface-muted);
95
+ border-radius: 6px;
96
+ overflow: hidden;
97
+ }
98
+ .bar-fill { height: 100%; border-radius: 6px; }
99
+ .bar-fill.step1 { background: var(--primary); opacity: 0.55; }
100
+ .bar-fill.step2 { background: var(--primary); opacity: 0.8; }
101
+ .bar-fill.total { background: var(--primary); }
102
+ .bar-fill.baseline { background: var(--text-muted); opacity: 0.5; }
103
+ .bar-value { font-family: ui-monospace, SFMono-Regular, monospace; text-align: right; }
104
+
105
+ @media (max-width: 600px) {
106
+ .bar-row { grid-template-columns: 1fr auto; }
107
+ .bar-row .bar-label { grid-column: 1 / -1; font-size: 0.85em; }
108
+ }
109
+ </style>
src/frontend/src/components/home/DemoPreviewCard.vue CHANGED
@@ -1,26 +1,33 @@
1
  <script setup>
 
 
2
  defineProps({
3
  title: { type: String, required: true },
4
  description: { type: String, required: true },
5
  icon: { type: String, default: 'flask' },
6
  thesisRef: { type: String, default: '' },
 
7
  })
8
  </script>
9
 
10
  <template>
11
- <div class="ui card demo-card">
 
12
  <div class="content">
13
  <div class="header"><i :class="['icon', icon]"></i>{{ title }}</div>
14
  <div class="meta" v-if="thesisRef">{{ thesisRef }}</div>
15
  <div class="description">{{ description }}</div>
16
  </div>
17
  <div class="extra content">
18
- <span class="ui tiny label">Coming soon</span>
 
19
  </div>
20
- </div>
21
  </template>
22
 
23
  <style scoped>
24
  .demo-card { width: 100% !important; }
25
  .demo-card .header .icon { margin-right: 0.5rem !important; color: var(--primary); }
 
 
26
  </style>
 
1
  <script setup>
2
+ import { RouterLink } from 'vue-router'
3
+
4
  defineProps({
5
  title: { type: String, required: true },
6
  description: { type: String, required: true },
7
  icon: { type: String, default: 'flask' },
8
  thesisRef: { type: String, default: '' },
9
+ to: { type: String, default: '' },
10
  })
11
  </script>
12
 
13
  <template>
14
+ <component :is="to ? RouterLink : 'div'" :to="to || undefined"
15
+ :class="['ui card demo-card', { linked: !!to }]">
16
  <div class="content">
17
  <div class="header"><i :class="['icon', icon]"></i>{{ title }}</div>
18
  <div class="meta" v-if="thesisRef">{{ thesisRef }}</div>
19
  <div class="description">{{ description }}</div>
20
  </div>
21
  <div class="extra content">
22
+ <span v-if="to" class="ui tiny green label">Open demo</span>
23
+ <span v-else class="ui tiny label">Coming soon</span>
24
  </div>
25
+ </component>
26
  </template>
27
 
28
  <style scoped>
29
  .demo-card { width: 100% !important; }
30
  .demo-card .header .icon { margin-right: 0.5rem !important; color: var(--primary); }
31
+ .demo-card.linked { cursor: pointer; }
32
+ .demo-card.linked:hover { box-shadow: var(--shadow-md) !important; transform: translateY(-2px); transition: 0.15s; }
33
  </style>
src/frontend/src/components/layout/NavBar.vue CHANGED
@@ -18,6 +18,7 @@ function close() { menuOpen.value = false }
18
  <div class="right menu hide-mobile">
19
  <RouterLink to="/" class="item" active-class="active" exact-active-class="active">Home</RouterLink>
20
  <RouterLink to="/cv" class="item" active-class="active">CV</RouterLink>
 
21
  <div class="item borderless">
22
  <ThemeToggle />
23
  </div>
@@ -37,6 +38,7 @@ function close() { menuOpen.value = false }
37
  <div v-if="menuOpen" class="ui vertical menu mobile-drawer show-mobile">
38
  <RouterLink to="/" class="item" @click="close">Home</RouterLink>
39
  <RouterLink to="/cv" class="item" @click="close">CV</RouterLink>
 
40
  </div>
41
  </nav>
42
  </template>
 
18
  <div class="right menu hide-mobile">
19
  <RouterLink to="/" class="item" active-class="active" exact-active-class="active">Home</RouterLink>
20
  <RouterLink to="/cv" class="item" active-class="active">CV</RouterLink>
21
+ <RouterLink to="/demos/coins" class="item" active-class="active">Demos</RouterLink>
22
  <div class="item borderless">
23
  <ThemeToggle />
24
  </div>
 
38
  <div v-if="menuOpen" class="ui vertical menu mobile-drawer show-mobile">
39
  <RouterLink to="/" class="item" @click="close">Home</RouterLink>
40
  <RouterLink to="/cv" class="item" @click="close">CV</RouterLink>
41
+ <RouterLink to="/demos/coins" class="item" @click="close">Demos</RouterLink>
42
  </div>
43
  </nav>
44
  </template>
src/frontend/src/router/index.js CHANGED
@@ -6,6 +6,12 @@ import NotFoundView from '../views/NotFoundView.vue'
6
  const routes = [
7
  { path: '/', name: 'home', component: HomeView, meta: { title: 'Andrej Janchevski — PhD Research' } },
8
  { path: '/cv', name: 'cv', component: CVView, meta: { title: 'CV — Andrej Janchevski' } },
 
 
 
 
 
 
9
  { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Not Found' } },
10
  ]
11
 
 
6
  const routes = [
7
  { path: '/', name: 'home', component: HomeView, meta: { title: 'Andrej Janchevski — PhD Research' } },
8
  { path: '/cv', name: 'cv', component: CVView, meta: { title: 'CV — Andrej Janchevski' } },
9
+ {
10
+ path: '/demos/coins',
11
+ name: 'demo-coins',
12
+ component: () => import('../views/demos/CoinsView.vue'),
13
+ meta: { title: 'COINs — KG Reasoning Demo' },
14
+ },
15
  { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Not Found' } },
16
  ]
17
 
src/frontend/src/stores/coinsDemo.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from 'pinia'
2
+ import { apiError } from '../api/client'
3
+ import { listDatasets, listModels, listQueryStructures, predict } from '../api/coins'
4
+
5
+ export const useCoinsDemoStore = defineStore('coinsDemo', {
6
+ state: () => ({
7
+ datasets: [],
8
+ models: [],
9
+ structures: [],
10
+ registryLoaded: false,
11
+ registryError: '',
12
+ datasetId: '',
13
+ queryStructure: '',
14
+ algorithm: '',
15
+ anchors: {},
16
+ variables: {},
17
+ variablePinned: {},
18
+ relations: {},
19
+ topK: 10,
20
+ loading: false,
21
+ error: '',
22
+ errorCode: '',
23
+ result: null,
24
+ }),
25
+
26
+ getters: {
27
+ selectedStructure(state) {
28
+ return state.structures.find((s) => s.id === state.queryStructure) || null
29
+ },
30
+ allowedAlgorithms(state) {
31
+ if (!state.datasetId || !state.queryStructure) return []
32
+ return state.models.filter(
33
+ (m) =>
34
+ (m.supported_query_structures || []).includes(state.queryStructure) &&
35
+ (m.available_datasets || []).includes(state.datasetId),
36
+ )
37
+ },
38
+ anchorIds() {
39
+ const s = this.selectedStructure
40
+ if (!s) return []
41
+ return s.nodes.filter((n) => n.type === 'anchor').map((n) => n.id)
42
+ },
43
+ variableIds() {
44
+ const s = this.selectedStructure
45
+ if (!s) return []
46
+ return s.nodes.filter((n) => n.type === 'variable').map((n) => n.id)
47
+ },
48
+ relationIds() {
49
+ const s = this.selectedStructure
50
+ if (!s) return []
51
+ return s.edges.map((e) => e.id)
52
+ },
53
+ canReason(state) {
54
+ if (!state.datasetId || !state.queryStructure || !state.algorithm) return false
55
+ if (state.loading) return false
56
+ for (const id of this.anchorIds) {
57
+ if (state.anchors[id]?.id == null) return false
58
+ }
59
+ for (const id of this.relationIds) {
60
+ if (state.relations[id]?.id == null) return false
61
+ }
62
+ for (const id of this.variableIds) {
63
+ if (state.variablePinned[id] && state.variables[id]?.id == null) return false
64
+ }
65
+ return true
66
+ },
67
+ },
68
+
69
+ actions: {
70
+ async loadRegistry() {
71
+ this.registryError = ''
72
+ try {
73
+ const [datasetsRes, modelsRes, structuresRes] = await Promise.all([
74
+ listDatasets(),
75
+ listModels(),
76
+ listQueryStructures(),
77
+ ])
78
+ this.datasets = datasetsRes?.datasets || []
79
+ this.models = modelsRes?.models || []
80
+ this.structures = structuresRes?.query_structures || []
81
+ this.registryLoaded = true
82
+ if (!this.datasetId && this.datasets.length) this.setDataset(this.datasets[0].id)
83
+ if (!this.queryStructure && this.structures.length) this.setStructure(this.structures[0].id)
84
+ } catch (e) {
85
+ this.registryError = apiError(e)
86
+ }
87
+ },
88
+
89
+ setDataset(id) {
90
+ if (this.datasetId === id) return
91
+ this.datasetId = id
92
+ this.anchors = {}
93
+ this.variables = {}
94
+ this.variablePinned = {}
95
+ this.result = null
96
+ this.error = ''
97
+ this.errorCode = ''
98
+ this.autoSelectAlgorithm()
99
+ },
100
+
101
+ setStructure(id) {
102
+ if (this.queryStructure === id) return
103
+ this.queryStructure = id
104
+ this.anchors = {}
105
+ this.variables = {}
106
+ this.variablePinned = {}
107
+ this.relations = {}
108
+ this.result = null
109
+ this.error = ''
110
+ this.errorCode = ''
111
+ this.autoSelectAlgorithm()
112
+ },
113
+
114
+ setAlgorithm(algorithm) {
115
+ this.algorithm = algorithm
116
+ },
117
+
118
+ autoSelectAlgorithm() {
119
+ const allowed = this.allowedAlgorithms
120
+ if (!allowed.length) {
121
+ this.algorithm = ''
122
+ return
123
+ }
124
+ if (!allowed.find((m) => m.algorithm === this.algorithm)) {
125
+ this.algorithm = allowed[0].algorithm
126
+ }
127
+ },
128
+
129
+ setAnchor(id, entity) {
130
+ if (entity) this.anchors = { ...this.anchors, [id]: entity }
131
+ else {
132
+ const { [id]: _removed, ...rest } = this.anchors
133
+ this.anchors = rest
134
+ }
135
+ },
136
+
137
+ setRelation(id, relation) {
138
+ if (relation) this.relations = { ...this.relations, [id]: relation }
139
+ else {
140
+ const { [id]: _removed, ...rest } = this.relations
141
+ this.relations = rest
142
+ }
143
+ },
144
+
145
+ setVariable(id, entity) {
146
+ if (entity) this.variables = { ...this.variables, [id]: entity }
147
+ else {
148
+ const { [id]: _removed, ...rest } = this.variables
149
+ this.variables = rest
150
+ }
151
+ },
152
+
153
+ setVariablePinned(id, pinned) {
154
+ this.variablePinned = { ...this.variablePinned, [id]: pinned }
155
+ if (!pinned) this.setVariable(id, null)
156
+ },
157
+
158
+ setTopK(k) {
159
+ this.topK = k
160
+ },
161
+
162
+ async reason() {
163
+ if (!this.canReason) return
164
+ this.loading = true
165
+ this.error = ''
166
+ this.errorCode = ''
167
+ this.result = null
168
+ const payload = {
169
+ dataset_id: this.datasetId,
170
+ algorithm: this.algorithm,
171
+ query_structure: this.queryStructure,
172
+ anchors: Object.fromEntries(
173
+ Object.entries(this.anchors).map(([k, v]) => [k, v.id]),
174
+ ),
175
+ relations: Object.fromEntries(
176
+ Object.entries(this.relations).map(([k, v]) => [k, v.id]),
177
+ ),
178
+ top_k: this.topK,
179
+ }
180
+ const pinnedVars = Object.fromEntries(
181
+ Object.entries(this.variables)
182
+ .filter(([k]) => this.variablePinned[k])
183
+ .map(([k, v]) => [k, v.id]),
184
+ )
185
+ if (Object.keys(pinnedVars).length) payload.variables = pinnedVars
186
+
187
+ try {
188
+ this.result = await predict(payload)
189
+ } catch (e) {
190
+ const err = e?.response?.data?.error
191
+ this.errorCode = err?.code || ''
192
+ this.error = err?.message || apiError(e)
193
+ } finally {
194
+ this.loading = false
195
+ }
196
+ },
197
+ },
198
+ })
src/frontend/src/views/HomeView.vue CHANGED
@@ -12,6 +12,7 @@ const demos = [
12
  description: 'Quadratically-accelerated link prediction and conjunctive query answering with community-informed graph coarsening.',
13
  icon: 'project diagram',
14
  thesisRef: 'Thesis § 3.1',
 
15
  },
16
  {
17
  title: 'MultiProxAn — Graph Generation',
 
12
  description: 'Quadratically-accelerated link prediction and conjunctive query answering with community-informed graph coarsening.',
13
  icon: 'project diagram',
14
  thesisRef: 'Thesis § 3.1',
15
+ to: '/demos/coins',
16
  },
17
  {
18
  title: 'MultiProxAn — Graph Generation',
src/frontend/src/views/demos/CoinsView.vue ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import PageSection from '../../components/layout/PageSection.vue'
4
+ import QueryStructurePicker from '../../components/coins/QueryStructurePicker.vue'
5
+ import QueryGraph from '../../components/coins/QueryGraph.vue'
6
+ import SearchableEntityDropdown from '../../components/coins/SearchableEntityDropdown.vue'
7
+ import SearchableRelationDropdown from '../../components/coins/SearchableRelationDropdown.vue'
8
+ import AlgorithmSelector from '../../components/coins/AlgorithmSelector.vue'
9
+ import ResultsDashboard from '../../components/coins/ResultsDashboard.vue'
10
+ import { useCoinsDemoStore } from '../../stores/coinsDemo'
11
+ import { sampleQuery } from '../../api/coins'
12
+
13
+ const store = useCoinsDemoStore()
14
+ const prefillError = ref('')
15
+ const prefilling = ref(false)
16
+
17
+ onMounted(() => {
18
+ if (!store.registryLoaded) store.loadRegistry()
19
+ })
20
+
21
+ const selectedDataset = computed(() =>
22
+ store.datasets.find((d) => d.id === store.datasetId) || null,
23
+ )
24
+
25
+ function setAnchor(id, val) { store.setAnchor(id, val) }
26
+ function setRelation(id, val) { store.setRelation(id, val) }
27
+ function setVariable(id, val) { store.setVariable(id, val) }
28
+
29
+ async function prefillRandom() {
30
+ if (!store.datasetId || !store.queryStructure) return
31
+ prefillError.value = ''
32
+ prefilling.value = true
33
+ try {
34
+ const seed = new Date().toISOString().slice(0, 10)
35
+ const res = await sampleQuery(store.datasetId, store.queryStructure, 1, seed)
36
+ const query = (res?.queries || [])[0]
37
+ if (!query) {
38
+ prefillError.value = 'Could not find a valid query — try again.'
39
+ return
40
+ }
41
+ for (const [id, entity] of Object.entries(query.anchors)) {
42
+ store.setAnchor(id, entity)
43
+ }
44
+ for (const [id, relation] of Object.entries(query.relations)) {
45
+ store.setRelation(id, relation)
46
+ }
47
+ if (query.variables) {
48
+ for (const [id, entity] of Object.entries(query.variables)) {
49
+ if (store.variablePinned[id]) {
50
+ store.setVariable(id, entity)
51
+ }
52
+ }
53
+ }
54
+ } catch (e) {
55
+ prefillError.value = e?.message || 'Prefill failed'
56
+ } finally {
57
+ prefilling.value = false
58
+ }
59
+ }
60
+
61
+ const errorBanner = computed(() => {
62
+ if (!store.error) return null
63
+ if (store.errorCode === 'INFERENCE_BUSY') {
64
+ return { kind: 'warning', text: 'Another inference is running — retry in a moment.' }
65
+ }
66
+ if (store.errorCode === 'MODEL_UNAVAILABLE') {
67
+ return { kind: 'error', text: `${store.error} Try a different algorithm or dataset.` }
68
+ }
69
+ if (store.errorCode === 'INFERENCE_ERROR') {
70
+ return { kind: 'error', text: `No valid answers: ${store.error}` }
71
+ }
72
+ return { kind: 'error', text: store.error }
73
+ })
74
+
75
+ const structure = computed(() => store.selectedStructure)
76
+ </script>
77
+
78
+ <template>
79
+ <div class="coins-demo">
80
+ <header class="coins-head">
81
+ <h1 class="ui header">
82
+ <i class="project diagram icon"></i>
83
+ COINs — Knowledge-Graph Reasoning
84
+ </h1>
85
+ <p class="muted">
86
+ Community-informed graph coarsening accelerates link prediction and conjunctive query answering.
87
+ Pick a dataset, assemble a query by filling anchors and relations, then hit
88
+ <strong>Reason</strong> to score candidate entities against the KG.
89
+ Thesis § 3.1.
90
+ </p>
91
+ </header>
92
+
93
+ <div v-if="store.registryError" class="ui negative message">
94
+ <div class="header">Backend unreachable</div>
95
+ <p>{{ store.registryError }}</p>
96
+ </div>
97
+
98
+ <div v-else-if="!store.registryLoaded" class="ui placeholder">
99
+ <div class="line"></div><div class="line"></div><div class="line"></div>
100
+ </div>
101
+
102
+ <template v-else>
103
+ <PageSection title="Setup" :collapsible="false">
104
+ <div class="setup-row">
105
+ <div class="setup-col">
106
+ <label class="setup-label">Dataset</label>
107
+ <select class="setup-control" :value="store.datasetId"
108
+ @change="store.setDataset($event.target.value)">
109
+ <option v-for="d in store.datasets" :key="d.id" :value="d.id">
110
+ {{ d.name }} ({{ d.num_entities }} entities · {{ d.num_relations }} relations)
111
+ </option>
112
+ </select>
113
+ <div v-if="selectedDataset" class="muted tiny">{{ selectedDataset.description }}</div>
114
+ </div>
115
+
116
+ <div class="setup-col">
117
+ <AlgorithmSelector
118
+ :algorithms="store.allowedAlgorithms"
119
+ :model-value="store.algorithm"
120
+ @update:modelValue="store.setAlgorithm($event)" />
121
+ </div>
122
+
123
+ <div class="setup-col">
124
+ <label class="setup-label">Top-K: {{ store.topK }}</label>
125
+ <input type="range" min="1" max="10" step="1" class="setup-range"
126
+ :value="store.topK"
127
+ @input="store.setTopK(Number($event.target.value))" />
128
+ </div>
129
+ </div>
130
+ </PageSection>
131
+
132
+ <PageSection title="Query structure" :collapsible="false">
133
+ <QueryStructurePicker
134
+ :structures="store.structures"
135
+ :model-value="store.queryStructure"
136
+ @update:modelValue="store.setStructure($event)" />
137
+ </PageSection>
138
+
139
+ <PageSection title="Query graph" :collapsible="false">
140
+ <div class="prefill-row">
141
+ <button type="button" class="prefill-btn" @click="prefillRandom"
142
+ :disabled="prefilling || !store.datasetId">
143
+ <i class="random icon"></i>
144
+ {{ prefilling ? 'Prefilling…' : 'Prefill with a random query' }}
145
+ </button>
146
+ <span v-if="prefillError" class="muted error">{{ prefillError }}</span>
147
+ </div>
148
+
149
+ <div v-if="structure" class="graph-wrap">
150
+ <QueryGraph :structure="structure" mode="edit"
151
+ :cell-min-width="260" :cell-min-height="130" :pad="44">
152
+ <template v-for="n in structure.nodes.filter((n) => n.type === 'anchor')"
153
+ :key="'slot-' + n.id" #[`node-${n.id}`]>
154
+ <div class="slot-node">
155
+ <div class="slot-caption">{{ n.label || n.id }}</div>
156
+ <SearchableEntityDropdown
157
+ :dataset-id="store.datasetId"
158
+ :model-value="store.anchors[n.id] || null"
159
+ placeholder="Pick anchor"
160
+ @update:modelValue="setAnchor(n.id, $event)" />
161
+ </div>
162
+ </template>
163
+
164
+ <template v-for="n in structure.nodes.filter((n) => n.type === 'variable')"
165
+ :key="'slotv-' + n.id" #[`node-${n.id}`]>
166
+ <div class="slot-node">
167
+ <div class="slot-caption">
168
+ <label class="pin-toggle">
169
+ <input type="checkbox"
170
+ :checked="!!store.variablePinned[n.id]"
171
+ @change="store.setVariablePinned(n.id, $event.target.checked)" />
172
+ Pin {{ n.label || n.id }}
173
+ </label>
174
+ </div>
175
+ <SearchableEntityDropdown v-if="store.variablePinned[n.id]"
176
+ :dataset-id="store.datasetId"
177
+ :model-value="store.variables[n.id] || null"
178
+ placeholder="Pick variable"
179
+ @update:modelValue="setVariable(n.id, $event)" />
180
+ <span v-else class="let-model-pick">Let model sample</span>
181
+ </div>
182
+ </template>
183
+
184
+ <template #node-t>
185
+ <div class="slot-target" title="Query target">
186
+ <i class="question circle icon"></i>
187
+ <span>{{ structure.nodes.find(n => n.id === 't')?.label || 'target' }}</span>
188
+ </div>
189
+ </template>
190
+
191
+ <template v-for="e in structure.edges" :key="'slote-' + e.id" #[`edge-${e.id}`]>
192
+ <SearchableRelationDropdown
193
+ :dataset-id="store.datasetId"
194
+ :model-value="store.relations[e.id] || null"
195
+ :placeholder="e.label || 'Pick relation'"
196
+ @update:modelValue="setRelation(e.id, $event)" />
197
+ </template>
198
+ </QueryGraph>
199
+ </div>
200
+
201
+ <div class="reason-bar">
202
+ <button type="button" class="ui big primary button reason-btn"
203
+ :disabled="!store.canReason"
204
+ @click="store.reason()">
205
+ <i v-if="store.loading" class="spinner loading icon"></i>
206
+ <i v-else class="play icon"></i>
207
+ {{ store.loading ? 'Reasoning…' : 'Reason' }}
208
+ </button>
209
+ <div v-if="!store.canReason && !store.loading" class="muted reason-hint">
210
+ Fill all anchor and relation slots first.
211
+ </div>
212
+ </div>
213
+ </PageSection>
214
+
215
+ <div v-if="errorBanner" class="ui message" :class="errorBanner.kind">
216
+ <p>{{ errorBanner.text }}</p>
217
+ </div>
218
+
219
+ <PageSection v-if="store.result" title="Results" :collapsible="false">
220
+ <ResultsDashboard :result="store.result" />
221
+ </PageSection>
222
+ </template>
223
+ </div>
224
+ </template>
225
+
226
+ <style scoped>
227
+ .coins-demo { padding: 1rem 0 3rem; }
228
+ .coins-head { margin-bottom: 1.5rem; }
229
+ .coins-head h1.header { margin-bottom: 0.5rem; }
230
+ .muted { color: var(--text-muted); }
231
+ .muted.tiny { font-size: 0.8em; }
232
+ .muted.error { color: #c0392b; }
233
+
234
+ .setup-row {
235
+ display: grid;
236
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
237
+ gap: 1rem 1.5rem;
238
+ align-items: start;
239
+ }
240
+ .setup-col {
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: 0.3rem;
244
+ min-width: 0;
245
+ }
246
+ .setup-label {
247
+ font-size: 0.8em;
248
+ color: var(--text-muted);
249
+ text-transform: uppercase;
250
+ letter-spacing: 0.04em;
251
+ line-height: 1.2;
252
+ min-height: 1.2em;
253
+ }
254
+ .setup-control,
255
+ .setup-range {
256
+ height: 2.4rem;
257
+ padding: 0 0.6rem;
258
+ border: 1px solid var(--border);
259
+ border-radius: 0.4rem;
260
+ background: var(--surface);
261
+ color: var(--text);
262
+ font: inherit;
263
+ box-sizing: border-box;
264
+ width: 100%;
265
+ }
266
+ .setup-range { padding: 0 0.4rem; }
267
+ .setup-range:focus,
268
+ .setup-control:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
269
+
270
+ .prefill-row { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
271
+ .prefill-btn {
272
+ display: inline-flex;
273
+ align-items: center;
274
+ gap: 0.4rem;
275
+ padding: 0.5rem 0.9rem;
276
+ border-radius: 0.5rem;
277
+ border: 1px solid var(--primary);
278
+ background: var(--primary-soft);
279
+ color: var(--primary-strong);
280
+ font: inherit;
281
+ font-size: 0.9em;
282
+ font-weight: 600;
283
+ cursor: pointer;
284
+ transition: 0.15s;
285
+ }
286
+ .prefill-btn:hover:enabled { background: var(--primary); color: white; }
287
+ .prefill-btn:disabled { opacity: 0.55; cursor: not-allowed; }
288
+ .prefill-btn i.icon { margin: 0; }
289
+
290
+ .graph-wrap {
291
+ background: var(--surface);
292
+ border: 1px solid var(--border);
293
+ border-radius: 0.6rem;
294
+ padding: 2rem 1rem;
295
+ margin: 0 auto 1.5rem;
296
+ min-height: 320px;
297
+ display: flex;
298
+ align-items: center;
299
+ justify-content: center;
300
+ }
301
+
302
+ .slot-node {
303
+ position: relative;
304
+ display: inline-flex;
305
+ flex-direction: column;
306
+ align-items: center;
307
+ }
308
+ .slot-caption {
309
+ position: absolute;
310
+ bottom: calc(100% + 0.25rem);
311
+ left: 50%;
312
+ transform: translateX(-50%);
313
+ font-size: 0.72em;
314
+ color: var(--text-muted);
315
+ text-transform: uppercase;
316
+ letter-spacing: 0.04em;
317
+ white-space: nowrap;
318
+ }
319
+ .pin-toggle { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; pointer-events: auto; }
320
+ .let-model-pick {
321
+ display: inline-block;
322
+ padding: 0.4em 0.9em;
323
+ border-radius: 999px;
324
+ border: 1px dashed var(--border);
325
+ color: var(--text-muted);
326
+ font-size: 0.85em;
327
+ background: var(--surface-muted);
328
+ font-style: italic;
329
+ white-space: nowrap;
330
+ }
331
+
332
+ .slot-target {
333
+ display: inline-flex;
334
+ align-items: center;
335
+ gap: 0.3rem;
336
+ padding: 0.5em 0.9em;
337
+ border-radius: 999px;
338
+ background: var(--surface);
339
+ border: 2px dashed var(--primary);
340
+ font-weight: 700;
341
+ color: var(--primary-strong);
342
+ }
343
+ .slot-target i.icon { margin: 0; }
344
+
345
+ .reason-bar {
346
+ display: flex;
347
+ gap: 0.75rem;
348
+ align-items: center;
349
+ justify-content: center;
350
+ flex-wrap: wrap;
351
+ margin: 1rem 0;
352
+ }
353
+ .reason-btn { background: var(--primary) !important; }
354
+ .reason-btn:hover:enabled { background: var(--primary-strong) !important; }
355
+ .reason-hint { font-size: 0.85em; }
356
+
357
+ .ui.message { margin: 1rem 0 !important; }
358
+ </style>