structured-gist 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structured_gist/__init__.py +12 -0
- structured_gist/claim_list.py +371 -0
- structured_gist/claim_list.schema.json +50 -0
- structured_gist/lint_outline.py +1020 -0
- structured_gist-0.9.0.dist-info/METADATA +361 -0
- structured_gist-0.9.0.dist-info/RECORD +10 -0
- structured_gist-0.9.0.dist-info/WHEEL +4 -0
- structured_gist-0.9.0.dist-info/entry_points.txt +3 -0
- structured_gist-0.9.0.dist-info/licenses/LICENSE +202 -0
- structured_gist-0.9.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Deterministic linter and claim-list renderer for structured-gist outlines.
|
|
2
|
+
|
|
3
|
+
Modules: ``structured_gist.lint_outline`` (``lint_text``) and
|
|
4
|
+
``structured_gist.claim_list`` (``render_claim_list``). stdlib only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("structured-gist")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claim-list render target for structured-gist outline trees.
|
|
4
|
+
|
|
5
|
+
Implements FR-018, FR-018a, FR-018b, FR-019, FR-020:
|
|
6
|
+
- Emits JSONL (one claim record per line)
|
|
7
|
+
- Each record: claim, modality, polarity, source_span, parent_id
|
|
8
|
+
- Validates against published schema
|
|
9
|
+
- Derived view of the existing outline tree (reuses lint_outline parser)
|
|
10
|
+
|
|
11
|
+
No external dependencies beyond Python 3 stdlib.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Tuple, Optional, Dict, Any
|
|
19
|
+
from io import StringIO
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
# Installed package: lint_outline sits beside this module
|
|
23
|
+
from .lint_outline import parse_line, extract_outline_from_text, lint_text
|
|
24
|
+
except ImportError:
|
|
25
|
+
# Script or flat import: add parent directory to path for lint_outline import
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / 'tests'))
|
|
27
|
+
|
|
28
|
+
from lint_outline import parse_line, extract_outline_from_text, lint_text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Load and cache the schema
|
|
32
|
+
def _load_schema() -> Dict[str, Any]:
|
|
33
|
+
"""Load the claim_list schema from the adjacent JSON file."""
|
|
34
|
+
schema_path = Path(__file__).parent / 'claim_list.schema.json'
|
|
35
|
+
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
36
|
+
return json.load(f)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SCHEMA = _load_schema()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _has_verb_predicate(text: str) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Heuristic: determine if text contains a verb/predicate.
|
|
45
|
+
|
|
46
|
+
Returns True if text contains:
|
|
47
|
+
- Common auxiliary/modal verbs (is, are, was, were, has, have, had, do, does, did,
|
|
48
|
+
can, could, will, would, should, must, may, might, shall)
|
|
49
|
+
- Common verbs (be, have, do, go, get, make, take, know, give, come, use, find, tell,
|
|
50
|
+
work, call, try, ask, need, feel, become, leave, put, mean, seem, help, talk, turn,
|
|
51
|
+
start, show, hear, play, run, move, like, live, believe, hold, bring, begin, let,
|
|
52
|
+
provide, contain, validate, check, require, allow, enable, disable, perform,
|
|
53
|
+
support, expect, represent, implement, define, create, generate, produce, process,
|
|
54
|
+
handle, manage, control, maintain, update, remove, add, delete, modify, retrieve,
|
|
55
|
+
store, load, save, open, close, read, write, execute, return, throw, catch, pass,
|
|
56
|
+
fail, succeed, etc.)
|
|
57
|
+
- Words ending in -ed (past tense forms)
|
|
58
|
+
- Words ending in -ing (gerund/progressive forms)
|
|
59
|
+
|
|
60
|
+
Does NOT count bare noun phrases like "Configuration" or "Name".
|
|
61
|
+
"""
|
|
62
|
+
if not text or not text.strip():
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Strip surrounding ** if present (from bold markdown)
|
|
66
|
+
cleaned = text.strip()
|
|
67
|
+
if cleaned.startswith('**') and cleaned.endswith('**'):
|
|
68
|
+
cleaned = cleaned[2:-2].strip()
|
|
69
|
+
|
|
70
|
+
if not cleaned:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
# Common auxiliary and modal verbs
|
|
74
|
+
auxiliaries = {
|
|
75
|
+
'is', 'are', 'was', 'were', 'been', 'be',
|
|
76
|
+
'has', 'have', 'had',
|
|
77
|
+
'does', 'do', 'did',
|
|
78
|
+
'can', 'could', 'will', 'would', 'should', 'must', 'may', 'might', 'shall',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Common content verbs (infinitive forms)
|
|
82
|
+
verbs = {
|
|
83
|
+
'go', 'get', 'make', 'take', 'know', 'give', 'come', 'use', 'find', 'tell',
|
|
84
|
+
'work', 'call', 'try', 'ask', 'need', 'feel', 'become', 'leave', 'put', 'mean',
|
|
85
|
+
'seem', 'help', 'talk', 'turn', 'start', 'show', 'hear', 'play', 'run', 'move',
|
|
86
|
+
'like', 'live', 'believe', 'hold', 'bring', 'begin', 'let', 'provide', 'contain',
|
|
87
|
+
'validate', 'check', 'require', 'allow', 'enable', 'disable', 'perform', 'support',
|
|
88
|
+
'expect', 'represent', 'implement', 'define', 'create', 'generate', 'produce',
|
|
89
|
+
'process', 'handle', 'manage', 'control', 'maintain', 'update', 'remove', 'add',
|
|
90
|
+
'delete', 'modify', 'retrieve', 'store', 'load', 'save', 'open', 'close',
|
|
91
|
+
'read', 'write', 'execute', 'return', 'throw', 'catch', 'pass', 'fail', 'succeed',
|
|
92
|
+
'resolve', 'render', 'emit', 'consume', 'accept', 'reject', 'parse', 'compute',
|
|
93
|
+
'apply', 'enforce', 'collect', 'gather', 'extract', 'combine', 'merge', 'split',
|
|
94
|
+
'build', 'develop', 'ship', 'test', 'verify', 'validate', 'inspect', 'audit',
|
|
95
|
+
'cover', 'fix', 'resolve', 'discover', 'investigate', 'determine', 'calculate',
|
|
96
|
+
'measure', 'record', 'track', 'flag', 'warn', 'report', 'document', 'explain',
|
|
97
|
+
'describe', 'analyze', 'examine', 'review', 'check', 'assess', 'evaluate',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Split text into words
|
|
101
|
+
words = cleaned.split()
|
|
102
|
+
|
|
103
|
+
for word in words:
|
|
104
|
+
# Remove punctuation for comparison
|
|
105
|
+
word_stripped = word.rstrip('.,;:!?\'")').lower()
|
|
106
|
+
|
|
107
|
+
# Check for auxiliary verbs
|
|
108
|
+
if word_stripped in auxiliaries:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# Check for common verbs (3rd person singular: adds, makes, validates, etc.)
|
|
112
|
+
if word_stripped[:-1] in verbs and word_stripped.endswith('s'):
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
# Check for past tense (-ed)
|
|
116
|
+
if word_stripped.endswith('ed') and len(word_stripped) > 3:
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# Check for progressive (-ing)
|
|
120
|
+
if word_stripped.endswith('ing') and len(word_stripped) > 4:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# Check infinitive forms
|
|
124
|
+
if word_stripped in verbs:
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_summary(attr_text: str, child_text: str) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Determine if a child's text is a near-verbatim restatement of the attribute.
|
|
133
|
+
A child is "summary" (excluded from emission) if it merely restates the parent
|
|
134
|
+
almost verbatim — use word overlap heuristic.
|
|
135
|
+
|
|
136
|
+
Returns True if >80% of words in child are present in attribute.
|
|
137
|
+
"""
|
|
138
|
+
if not attr_text or not child_text:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# Normalize both texts
|
|
142
|
+
attr_words = set(w.lower().rstrip('.,;:!?') for w in attr_text.split())
|
|
143
|
+
child_words = [w.lower().rstrip('.,;:!?') for w in child_text.split()]
|
|
144
|
+
|
|
145
|
+
if not child_words:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
# Count how many child words appear in attribute
|
|
149
|
+
overlap = sum(1 for w in child_words if w in attr_words)
|
|
150
|
+
overlap_ratio = overlap / len(child_words)
|
|
151
|
+
|
|
152
|
+
return overlap_ratio >= 0.8
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _validate_record(record: Dict[str, Any]) -> List[str]:
|
|
156
|
+
"""
|
|
157
|
+
Validate a claim record against the schema.
|
|
158
|
+
Returns list of validation errors (empty if valid).
|
|
159
|
+
"""
|
|
160
|
+
errors = []
|
|
161
|
+
|
|
162
|
+
# Check required keys
|
|
163
|
+
required_keys = {'claim', 'modality', 'polarity', 'source_span', 'parent_id'}
|
|
164
|
+
missing = required_keys - set(record.keys())
|
|
165
|
+
if missing:
|
|
166
|
+
errors.append(f"Missing required keys: {missing}")
|
|
167
|
+
|
|
168
|
+
# Check no extra keys
|
|
169
|
+
extra = set(record.keys()) - required_keys
|
|
170
|
+
if extra:
|
|
171
|
+
errors.append(f"Unexpected extra keys: {extra}")
|
|
172
|
+
|
|
173
|
+
# Check types and values
|
|
174
|
+
if 'claim' in record:
|
|
175
|
+
if not isinstance(record['claim'], str):
|
|
176
|
+
errors.append(f"'claim' must be string, got {type(record['claim'])}")
|
|
177
|
+
|
|
178
|
+
if 'modality' in record:
|
|
179
|
+
if not isinstance(record['modality'], str):
|
|
180
|
+
errors.append(f"'modality' must be string, got {type(record['modality'])}")
|
|
181
|
+
elif record['modality'] not in ['assertion', 'epistemic_hedge', 'deontic_hedge']:
|
|
182
|
+
errors.append(f"'modality' must be one of ['assertion', 'epistemic_hedge', 'deontic_hedge'], got {record['modality']}")
|
|
183
|
+
|
|
184
|
+
if 'polarity' in record:
|
|
185
|
+
if not isinstance(record['polarity'], str):
|
|
186
|
+
errors.append(f"'polarity' must be string, got {type(record['polarity'])}")
|
|
187
|
+
elif record['polarity'] not in ['affirmative', 'negative']:
|
|
188
|
+
errors.append(f"'polarity' must be one of ['affirmative', 'negative'], got {record['polarity']}")
|
|
189
|
+
|
|
190
|
+
if 'source_span' in record:
|
|
191
|
+
if not isinstance(record['source_span'], dict):
|
|
192
|
+
errors.append(f"'source_span' must be dict, got {type(record['source_span'])}")
|
|
193
|
+
else:
|
|
194
|
+
ss = record['source_span']
|
|
195
|
+
if 'start' not in ss:
|
|
196
|
+
errors.append("'source_span' missing 'start' key")
|
|
197
|
+
elif not isinstance(ss.get('start'), int):
|
|
198
|
+
errors.append(f"'source_span.start' must be int, got {type(ss.get('start'))}")
|
|
199
|
+
|
|
200
|
+
if 'end' not in ss:
|
|
201
|
+
errors.append("'source_span' missing 'end' key")
|
|
202
|
+
elif not isinstance(ss.get('end'), int):
|
|
203
|
+
errors.append(f"'source_span.end' must be int, got {type(ss.get('end'))}")
|
|
204
|
+
|
|
205
|
+
if 'text' not in ss:
|
|
206
|
+
errors.append("'source_span' missing 'text' key")
|
|
207
|
+
elif not isinstance(ss.get('text'), str):
|
|
208
|
+
errors.append(f"'source_span.text' must be string, got {type(ss.get('text'))}")
|
|
209
|
+
|
|
210
|
+
if 'parent_id' in record:
|
|
211
|
+
if not isinstance(record['parent_id'], str):
|
|
212
|
+
errors.append(f"'parent_id' must be string, got {type(record['parent_id'])}")
|
|
213
|
+
|
|
214
|
+
return errors
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_parent_id(depth: int, line_index: int) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Generate a stable parent_id for a node.
|
|
220
|
+
Uses depth and line index to create a stable identifier.
|
|
221
|
+
"""
|
|
222
|
+
return f"node_{depth}_{line_index}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def render_claim_list(outline_text: str) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Render an outline tree as a claim-list JSONL.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
outline_text: The outline text (any render mode: block, inline, or responsive)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
JSONL output (one JSON object per line, one line per claim)
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If any emitted record fails schema validation
|
|
237
|
+
"""
|
|
238
|
+
# Extract and parse the outline
|
|
239
|
+
outline_lines, is_block_mode = extract_outline_from_text(outline_text)
|
|
240
|
+
if not outline_lines:
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
# Parse each line into (depth, family, text)
|
|
244
|
+
parsed = []
|
|
245
|
+
for i, line in enumerate(outline_lines):
|
|
246
|
+
depth, family, text = parse_line(line, unit=2)
|
|
247
|
+
parsed.append((i, depth, family, text, line))
|
|
248
|
+
|
|
249
|
+
# Build parent index: map each line to its parent's index
|
|
250
|
+
parent_index = {}
|
|
251
|
+
for i, (line_idx, depth, family, text, line) in enumerate(parsed):
|
|
252
|
+
if depth == 0:
|
|
253
|
+
parent_index[i] = None
|
|
254
|
+
else:
|
|
255
|
+
# Find nearest ancestor with depth < current depth
|
|
256
|
+
parent = None
|
|
257
|
+
for j in range(i - 1, -1, -1):
|
|
258
|
+
_, p_depth, _, _, _ = parsed[j]
|
|
259
|
+
if p_depth < depth:
|
|
260
|
+
parent = j
|
|
261
|
+
break
|
|
262
|
+
parent_index[i] = parent
|
|
263
|
+
|
|
264
|
+
# Emit claims
|
|
265
|
+
records = []
|
|
266
|
+
output = StringIO()
|
|
267
|
+
|
|
268
|
+
for i, (line_idx, depth, family, text, original_line) in enumerate(parsed):
|
|
269
|
+
# Calculate character offset in original text
|
|
270
|
+
# Note: this is a simplified approximation; exact offsets would need more tracking
|
|
271
|
+
char_offset = outline_text.find(text)
|
|
272
|
+
if char_offset < 0:
|
|
273
|
+
char_offset = 0
|
|
274
|
+
|
|
275
|
+
# Determine parent_id
|
|
276
|
+
parent_i = parent_index[i]
|
|
277
|
+
if parent_i is not None:
|
|
278
|
+
parent_depth = parsed[parent_i][1]
|
|
279
|
+
parent_family = parsed[parent_i][2]
|
|
280
|
+
parent_id = _build_parent_id(parent_depth, parent_i)
|
|
281
|
+
else:
|
|
282
|
+
parent_id = "root"
|
|
283
|
+
|
|
284
|
+
# Decide whether to emit and what to emit
|
|
285
|
+
should_emit = False
|
|
286
|
+
claim_text = None
|
|
287
|
+
|
|
288
|
+
if family == 'dash':
|
|
289
|
+
# Concept nodes NEVER emit
|
|
290
|
+
should_emit = False
|
|
291
|
+
elif family == 'arrow':
|
|
292
|
+
# Explanation nodes ALWAYS emit
|
|
293
|
+
should_emit = True
|
|
294
|
+
claim_text = text
|
|
295
|
+
elif family in ('uroman', 'ualpha', 'lroman', 'lalpha'):
|
|
296
|
+
# Enumerator nodes ALWAYS emit
|
|
297
|
+
should_emit = True
|
|
298
|
+
claim_text = text
|
|
299
|
+
elif family == 'attr':
|
|
300
|
+
# Attribute nodes emit only if:
|
|
301
|
+
# 1. Own text contains a verb/predicate, OR
|
|
302
|
+
# 2. Has a non-summary ↪ child
|
|
303
|
+
|
|
304
|
+
# Check for verb in own text
|
|
305
|
+
if _has_verb_predicate(text):
|
|
306
|
+
should_emit = True
|
|
307
|
+
claim_text = text
|
|
308
|
+
else:
|
|
309
|
+
# Check for non-summary arrow child
|
|
310
|
+
# A child is the next line at greater depth whose parent is this line
|
|
311
|
+
child_text = None
|
|
312
|
+
for j in range(i + 1, len(parsed)):
|
|
313
|
+
child_line_idx, child_depth, child_family, child_text_raw, _ = parsed[j]
|
|
314
|
+
if child_depth <= depth:
|
|
315
|
+
# No more children
|
|
316
|
+
break
|
|
317
|
+
if child_depth == depth + 1 and child_family == 'arrow':
|
|
318
|
+
# This is a direct child
|
|
319
|
+
# Check if it's non-summary
|
|
320
|
+
if not _is_summary(text, child_text_raw):
|
|
321
|
+
should_emit = True
|
|
322
|
+
claim_text = child_text_raw
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
# Build and validate record
|
|
326
|
+
if should_emit and claim_text:
|
|
327
|
+
record = {
|
|
328
|
+
'claim': claim_text,
|
|
329
|
+
'modality': 'assertion', # Default; could be enhanced with hedge detection
|
|
330
|
+
'polarity': 'affirmative', # Default; could be enhanced with negation detection
|
|
331
|
+
'source_span': {
|
|
332
|
+
'start': char_offset,
|
|
333
|
+
'end': char_offset + len(claim_text),
|
|
334
|
+
'text': claim_text
|
|
335
|
+
},
|
|
336
|
+
'parent_id': parent_id
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# Validate record against schema
|
|
340
|
+
validation_errors = _validate_record(record)
|
|
341
|
+
if validation_errors:
|
|
342
|
+
raise ValueError(f"Schema validation failed for record: {validation_errors}\nRecord: {record}")
|
|
343
|
+
|
|
344
|
+
# Emit JSONL line
|
|
345
|
+
output.write(json.dumps(record) + '\n')
|
|
346
|
+
records.append(record)
|
|
347
|
+
|
|
348
|
+
return output.getvalue()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main():
|
|
352
|
+
"""CLI entry point."""
|
|
353
|
+
if len(sys.argv) > 1:
|
|
354
|
+
# Read from file
|
|
355
|
+
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
|
356
|
+
text = f.read()
|
|
357
|
+
else:
|
|
358
|
+
# Read from stdin
|
|
359
|
+
text = sys.stdin.read()
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
jsonl = render_claim_list(text)
|
|
363
|
+
print(jsonl, end='')
|
|
364
|
+
sys.exit(0)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == '__main__':
|
|
371
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Claim List Record",
|
|
4
|
+
"description": "A single claim extracted from an outline tree via the claim-list render target (FR-018). One record per emitted claim.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["claim", "modality", "polarity", "source_span", "parent_id"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"claim": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The claim text extracted from the source outline node."
|
|
12
|
+
},
|
|
13
|
+
"modality": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": ["assertion", "epistemic_hedge", "deontic_hedge"],
|
|
16
|
+
"description": "The epistemic/deontic status of the claim. 'assertion' is unqualified; 'epistemic_hedge' marks likelihood/certainty qualification; 'deontic_hedge' marks obligation/permission (MUST/SHOULD/MAY)."
|
|
17
|
+
},
|
|
18
|
+
"polarity": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"enum": ["affirmative", "negative"],
|
|
21
|
+
"description": "Whether the claim is affirmative or negative. Mirrors the 'negation' gold-fact category."
|
|
22
|
+
},
|
|
23
|
+
"source_span": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"description": "Character offset and quoted text identifying the source of this claim in the original document.",
|
|
26
|
+
"required": ["start", "end", "text"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"start": {
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"minimum": 0,
|
|
32
|
+
"description": "Character offset (0-indexed) of the start of the claim in the source."
|
|
33
|
+
},
|
|
34
|
+
"end": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"minimum": 0,
|
|
37
|
+
"description": "Character offset (exclusive) of the end of the claim in the source."
|
|
38
|
+
},
|
|
39
|
+
"text": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "The quoted text at source_span[start:end], for verification."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"parent_id": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Stable identifier of the emitting node's parent in the outline tree. Must be identical across re-renders of an unchanged outline."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|