API¶
The pipeline composes the stages; everything below it is a total function over plain data and can be used on its own.
Compose the stages. This module holds no logic of its own.
Every stage is a total function over plain data, so composition is ordinary function application and there is nothing here to test except that the stages fit together. That is not nothing: the failures worth catching at this level are the ones no single stage can see.
The important one is identity agreement. Five producers mint IRIs — the extraction pass, name resolution, member-write resolution, the def-use graph and the control-flow graph. If two of them name one entity differently, the graph contains two disconnected nodes where it should contain one, and every unit test still passes because each producer is self-consistent.
analyze(source, *, module='', file='<source>')
¶
Run every observation and inference stage over one module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path. |
''
|
file
|
str
|
A label for spans. |
'<source>'
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Source code in src\awl\pipeline.py
resolve_module(module, origin)
¶
Return the absolute path an import names.
from .params import ChargeParam inside battery.procedure names
battery.params. Extraction records the origin exactly as written,
because that is what the source says; turning it into a path is an
inference and belongs here.
Source code in src\awl\pipeline.py
to_ast_doc(source, *, module='', profile='ast', file='<source>', index=None)
¶
Parse, elide by profile, and collapse resolved constructors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path. |
''
|
profile
|
str
|
One of |
'ast'
|
file
|
str
|
A label for spans. |
'<source>'
|
index
|
dict
|
Module path to source, for the modules this one imports from. A parameter object is nearly always defined in another file, so without it the collapse almost never fires. |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
An |
Source code in src\awl\pipeline.py
to_compact(source, *, module='', profile='ast', spans=False, file='<source>', index=None)
¶
Run the chain and return the editor model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spans
|
bool
|
Keep source spans, which are the join key for in-place patching and for the trace overlay. Not needed to regenerate code. |
False
|
Source code in src\awl\pipeline.py
to_document(source, *, module='', profile='ast', file='<source>', index=None, layers=None, spans=None, identities=None, trivia=None)
¶
Build the JSON-LD document a profile calls for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path. |
''
|
profile
|
str
|
A named set of generator parameters: which wrappers are transparent,
which types go opaque, whether keywords fold, and which lookups run.
Defaults to |
'ast'
|
file
|
str
|
A label for spans. |
'<source>'
|
index
|
dict
|
Module path to source, for the modules this one imports from. |
None
|
layers
|
tuple of str
|
Overrides the profile's lookups, for a caller who wants one of them on
its own. Asking for |
None
|
spans
|
bool
|
Overrides the profile's :data: |
None
|
identities
|
bool
|
Overrides the profile's :data: |
None
|
trivia
|
bool
|
Overrides the profile's :data: |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
A |
Raises:
| Type | Description |
|---|---|
ValueError
|
If a layer is not one of :data: |
Source code in src\awl\pipeline.py
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | |
to_graph(source, *, module='', profile='ast', file='<source>', index=None, layers=None, spans=None, identities=None, trivia=None)
¶
Serialize :func:to_document as RDF, taking the same parameters.
Returns:
| Type | Description |
|---|---|
Graph
|
|
Notes
There is nothing here but a change of notation. What the graph contains is decided by the profile when the document is built, so the two cannot say different things about one program.
Source code in src\awl\pipeline.py
trace_run(source, call, *, module='', file='<source>')
¶
Run call under instrumentation and join the events onto the plan.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The text of the module being run, so its plan can be built. |
required |
call
|
callable
|
Invoked with no arguments. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
One entry per step: whether it ran, on which loop iterations, and for a branch which outcomes were observed. |
Source code in src\awl\pipeline.py
Stages¶
Observation: what the source says, with nothing inferred.
Extraction that guesses cannot be audited, and a wrong guess is
indistinguishable from an observation once it is in the graph. So this module
records from battery.params import ChargeParam as an import fact with an
alias hop and stops. Whether ChargeParam at a given call site is that
class is awl.resolve's judgement, and it carries a confidence tier.
User code is never imported. Tier 3 of the corpus references an experimental oold branch that need not be installed, and the notation is statically visible, so static analysis is sufficient.
extract(source, *, module, file='<source>')
¶
Read symbol facts and type info out of one module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path, used to mint identities. |
required |
file
|
str
|
A label for spans. |
'<source>'
|
Returns:
| Type | Description |
|---|---|
dict
|
Conforms to |
Notes
Never imports the module, and never decides what a name refers to.
Source code in src\awl\facts.py
Inference: bind names to identities, and say how sure we are.
Confidence is part of the data model, not metadata attached to it. Once meaning can be retrofitted by a model, declared and inferred semantics must never be indistinguishable: a pipeline that cannot mark its own output is worse than no pipeline.
Nothing here is a general resolver. The judgement is a lookup in an import table plus an optional hop through another module's exports.
resolve(facts, *, index=None, scheme='py')
¶
Bind each use to an identity with a confidence tier.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
facts
|
dict
|
A |
required |
index
|
dict
|
Module path to that module's |
None
|
scheme
|
str
|
Language dimension passed through to minting. |
'py'
|
Returns:
| Type | Description |
|---|---|
dict
|
Conforms to |
Source code in src\awl\resolve.py
resolve_writes(facts, *, scheme='py', imported=None)
¶
Resolve each attribute write to the member it targets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
facts
|
dict
|
A |
required |
scheme
|
str
|
Language dimension passed through to minting. |
'py'
|
imported
|
dict
|
Classes declared in the modules this one imports from, by name. A write walks declared fields, and a declaration lives in the module that made it, so without these an imported class resolves for the collapse and not for the write: known and unknown in the same pass. |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
This turns "an attribute named e_mod was assigned at line 74" into "the
modulus of elasticity of a tensile test specimen, reached from a tensile
test dataset, was assigned at line 74, by ModulusOfElasticity.from_pint".
Bindings and writes are replayed in source order within each function,
so a local alias is in scope for the writes that follow it and the rooted
chain is carried across it. s = dataset.specimen followed by s.e_mod
= ... therefore produces the same member, owner and root as the direct
form, differing only in confidence.
A hop that cannot be walked yields AMBIGUOUS rather than a guess.
Source code in src\awl\_writes.py
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | |
Profile-driven cutoff: order, fold, elide.
Three behaviours, not one. Order materializes the two orderings, so statement sequence is queryable without walking an RDF collection. Fold merges a keyword argument into its call while keeping its name. Elide unwraps a transparent wrapper into its parent and reduces an opaque expression to a single node holding its source text.
The distinction between folding and eliding a keyword is load-bearing.
Treating keyword as transparent and splicing out its value discards the
argument name, turning charge(ChargeParam(target_voltage=4.2, c_rate=0.23))
into ChargeParam(4.2, 0.23). That is a semantic change rather than a
formatting one, and it also leaves the collapse with nothing to match on.
elide(doc, *, profile='ast', source='')
¶
Apply a cutoff profile to an AST document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list
|
An |
required |
profile
|
str
|
One of |
'ast'
|
source
|
str
|
Original text, used to populate |
''
|
Returns:
| Type | Description |
|---|---|
dict or list
|
A new document; the input is not mutated. |
Raises:
| Type | Description |
|---|---|
KeyError
|
If the profile is unknown, rather than silently doing nothing. |
Source code in src\awl\elide.py
rewrap_statements(node)
¶
Put back the Expr wrappers that elision dropped.
Python needs a statement to hold an expression in a body. Which items
need one is entirely derivable — anything in a statement list that is not
itself a statement — so dropping the wrapper costs nothing and restoring
it needs no record of what was removed. That is what makes Expr
elidable on the profile that regenerates code, where a lossy elision would
not be.
Source code in src\awl\elide.py
unfold(doc)
¶
Restore everything elision rewrote reversibly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list
|
An |
required |
Returns:
| Type | Description |
|---|---|
dict or list
|
A document whose calls carry |
Source code in src\awl\elide.py
unfold_node(node, *, type_key='_type')
¶
Reverse keyword folding for one node.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node
|
dict
|
A node that may carry |
required |
type_key
|
str
|
The key naming a node type. The compact form spells it |
'_type'
|
Returns:
| Type | Description |
|---|---|
dict
|
The node with its |
Notes
Folding is a rewrite, not a loss, and this is what makes that true. Every profile folds because of it, including the faithful one: without a working inverse the profile that regenerates code could not fold, and the constructor collapse could never fire where it is most useful.
Source code in src\awl\elide.py
Type-driven collapse of call subtrees into typed nodes, and its inverse.
A call whose callee resolves to an annotated type is rewritten into a single node interpreted by that type's own context, so the call site carries meaning without the author writing any linked data.
The mechanism is what is new here, not the goal. Other systems reach the same semantic target by attaching meaning to a declared signature; this rewrites a call site's subtree using the resolved callee's context. Code-indexing systems keep the call as a call and merely point at the callee.
The collapse is a representation change rather than a projection, so it has an
inverse. Without :func:expand an editor could display a workflow and never
write one back.
callee_of(node)
¶
Return the local class name a collapsed node names, or None.
Read from @type when it carries a bare term. A term resolves through
the context and is therefore both the class name and, once mapped, the
IRI; a CURIE or absolute IRI is emitted verbatim and names no local class.
That distinction is what removes the need for a separate _callee.
Source code in src\awl\collapse.py
collapse(doc, *, types, resolved, embed_context=True, keep_spans=False)
¶
Rewrite resolved constructor calls into typed nodes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list
|
An |
required |
types
|
dict
|
|
required |
resolved
|
dict
|
Local name to symbol name. A name absent here is not collapsed, which is how an ambiguous binding stays a plain call: a wrong type is worse than no type. |
required |
embed_context
|
bool
|
Emit a per-node |
True
|
keep_spans
|
bool
|
Emit the source span as |
False
|
Returns:
| Type | Description |
|---|---|
dict or list
|
A new document. |
Source code in src\awl\collapse.py
expand(node)
¶
Rebuild a Call document from a typed node.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node
|
dict or list
|
A document that may contain collapsed nodes. |
required |
Returns:
| Type | Description |
|---|---|
dict or list
|
An |
Raises:
| Type | Description |
|---|---|
ValueError
|
If a typed node carries no |
Notes
Field insertion order is the keyword order, which JSON objects and Python dictionaries both preserve, so the restored call reads as it was written.
Source code in src\awl\collapse.py
Compact AST codec: the editor model for AWL-LD workflows.
Both ends speak AstDoc, so this composes with the elision stage.
Three node forms, named rather than punctuated:
========================== =========================================
{"@type": "While", ...} a typed node, the same key a collapsed
constructor uses
{"literal": 4.2} a constant
{"var": "i"} a name reference
========================== =========================================
The node type uses the JSON-LD keyword rather than a plain word, and that is
a correctness requirement rather than a style choice. A node carries its
fields as sibling keys, and AST field names are arbitrary identifiers:
ExceptHandler has a field literally called type. Spelling the node
type type silently destroyed every try/except in the standard
library. @ cannot appear in a Python identifier, so the keyword namespace
is the only one a field can never occupy.
literal and var stay plain words because they are complete nodes on
their own and never sit beside fields, so nothing can collide with them.
An earlier revision used _, c and $, chosen to save bytes before
the document was JSON-LD. They saved about four percent and cost a reader
having to learn a private punctuation scheme sitting next to type, in a
document meant to be edited by hand. type in particular now means one
thing everywhere: an ast node type and a collapsed class name are both
"what this is".
@value was the obvious JSON-LD choice for a literal and is unusable here:
a value object may carry only @value, type, @language, @index
and @direction, so a literal could not also carry argument_index.
decode(node)
¶
Decode the compact form back into a live AST node.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node
|
dict or list
|
A |
required |
Returns:
| Type | Description |
|---|---|
AST or list
|
Ready for :func: |
Notes
Every field of the target class is rebuilt from cls._fields, which is
what makes the encoder's omissions safe. Copying only the present keys
works on a recent interpreter and breaks on an older one: 3.11 raises
AttributeError unparsing a Module with no type_ignores where
3.13 returns the source.
Source code in src\awl\compact.py
dumps(doc, *, width=88, indent=1, _depth=0)
¶
Serialize a compact document, inlining whatever fits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
Any
|
A |
required |
width
|
int
|
The column a line may reach before its structure is broken open. |
88
|
indent
|
int
|
Spaces per level. |
1
|
Returns:
| Type | Description |
|---|---|
str
|
JSON in which a small structure stays on one line and a large one breaks. |
Notes
json.dumps(indent=...) puts every element of every structure on its own
line, so {"var": "i"} costs three lines and a two-argument call costs a
page. That is not more readable, only taller: the shape of a node is
easiest to see when the node fits on one line.
Source code in src\awl\compact.py
encode(doc, *, keep_spans=False)
¶
Encode an AstDoc into the compact form.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list
|
An |
required |
keep_spans
|
bool
|
Keep a |
False
|
Returns:
| Type | Description |
|---|---|
dict or list
|
A |
Notes
Rules: _type becomes type; a bare Constant becomes
{"literal": value}; a bare Name becomes {"var": id}; an
operator becomes its bare name; ctx, positions, nulls, empty lists and
the derivable orderings are dropped.
Source code in src\awl\compact.py
link_names(doc, bindings)
¶
Return doc with each name reference pointing at what it refers to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list or scalar
|
A |
required |
bindings
|
list of dict
|
What :func: |
required |
Returns:
| Type | Description |
|---|---|
dict or list or scalar
|
A new document. A |
Notes
Joined by span where the document has one, because a name is scope-blind
and two functions may both call something named run. Where it has no
span the join falls back to the name, and only when every use of that name
in the module resolved to one identity: an ambiguous name is left
unresolved rather than pointed at whichever binding was seen last.
The editor model does not carry this. It regenerates source from the name
as written, and what that name refers to is a judgement with a confidence
behind it, which the names lookup records as such.
Source code in src\awl\compact.py
link_steps(doc, steps)
¶
Return doc with each statement carrying the identity the plan minted for it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list or scalar
|
A |
required |
steps
|
list of dict
|
What :func: |
required |
Returns:
| Type | Description |
|---|---|
dict or list or scalar
|
A new document. A statement the plan recorded gains |
Notes
The plan already mints an identity for each statement, from the same
ast.stmt the tree is encoded from, and then throws it away on the tree's
side. The two layers then described one statement as two nodes, joined only
by carrying equal span coordinates, so a query that wanted the plan's
successor and the tree's callee had to match four numbers to say "the same
statement". That is matching by coincidence: it is the objection to joining
on a name, one level down.
With the identity on both, there is nothing to join. The tree's statement
and the plan's step are the same subject, and the plan's next and
when_true land on the node that carries the source.
Matched here by span, but only within one parse of one file, where the coordinates come from the same tree that produced both sides. That is a build-time lookup, not something a reader of the document has to repeat.
Source code in src\awl\compact.py
link_trivia(doc, source)
¶
Return doc with each statement carrying the comment written about it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list or scalar
|
A |
required |
source
|
str
|
The text the document was built from. |
required |
Returns:
| Type | Description |
|---|---|
dict or list or scalar
|
A new document. A statement gains |
Notes
The syntax tree has no comment node, so without this a comment exists only in the source and no projection of the document can see it. Derived from comment positions against the spans the document already carries, rather than from a second parse: a concrete-syntax library would say the same thing and would make the document layer depend on one.
One line, and only one. A comment beside a statement is unambiguously about it. The single line directly above it, at its own indentation, usually is. A run above that is not claimed: nothing here can tell a banner from commented-out code, and claiming four lines as one statement's explanation would put dead code into a field that reads as prose.
Two places that are not statements, both of which strand a comment otherwise: the end of a block, after its last statement, and the head and tail of the file. A comment at the end of a body would otherwise attach to whatever follows the block, which is outside it.
Source code in src\awl\compact.py
name_spans(doc, *, file='')
¶
Return doc with each span's four numbers named.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list or scalar
|
A |
required |
file
|
str
|
Recorded in each span, since a position means nothing without it. |
''
|
Returns:
| Type | Description |
|---|---|
dict or list or scalar
|
A new document. |
Notes
Two shapes for one fact, which is worth stating rather than hiding. The editor holds a span as four numbers because it patches source with them and reads them by position. A document that carries meaning cannot: a bare array says which four numbers, never which is the line and which the column, and a consumer has to know the order by convention. The plan and the def-use graph already name them, so naming them here is what keeps one spelling across the whole document rather than two.
Source code in src\awl\compact.py
number_items(doc)
¶
Return doc with every ordered statement carrying its sibling slot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict or list or scalar
|
A |
required |
Returns:
| Type | Description |
|---|---|
dict or list or scalar
|
A new document. |
Notes
The editor model leaves the number out, because the array already says it
and an editor that reorders a body would leave it stale. A document that is
going to be projected cannot: @container: @list yields an RDF
collection, a collection yields members rather than positions, and SPARQL
1.1 property paths have only *, + and ?, so a query downstream
cannot count the rdf:rest hops back. This integer is the query surface,
added at the one point where position stops being recoverable.
Source code in src\awl\compact.py
Reaching definitions and def-use edges: the backbone of value provenance.
An ordered syntax tree says what the code is. It does not say where a value
came from, because nothing links a name being read to the binding that produced
it. This module adds that link, which is what makes the ast profile able to
answer provenance questions rather than only structural ones.
Two properties worth stating, because both were initially got wrong.
Provenance does not need typing. Following a value back through
linear["strain"].pint.to_base_units().pint.magnitude never requires knowing
what those calls mean, only that the value flowed through them. Chains that are
impossible to type are ordinary to trace.
A name may have several reaching definitions. Taking only the most recent one silently drops a dependency whenever a value is assigned in both arms of a branch, so definitions are tracked as sets and merged at every join.
Known limitation: a comprehension target is not a definition
ys = [f(x) for x in xs] binds x, and this module does not record it.
Comprehensions have their own scope in Python 3, so binding x in the
enclosing environment would be wrong, and binding it correctly needs a nested
scope the walker does not model.
The chain does not break: ys still depends on xs and on f,
because the dependency is taken from every name read across the whole
expression. What is unresolvable is x itself, so a question of the form
"which binding does this comprehension variable refer to" has no answer. The
same applies to the target of a generator expression and to a walrus inside
one. Worth fixing when comprehension-heavy code matters; the fix is a nested
scope, not a special case.
analyze(source, *, module='', file='<source>')
¶
Return the def-use graph of one module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path, used to mint definition identities. |
''
|
file
|
str
|
A label for spans. |
'<source>'
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Definitions are tracked as sets and merged at every control-flow join, so a name bound in both arms of a branch reaches its uses through both. A loop body is replayed twice, which picks up a loop-carried dependency without iterating to a fixpoint.
Nothing here requires a type. That is the point: a chain that cannot be typed can still be traced.
Source code in src\awl\dataflow.py
The control-flow graph: which steps are connected by execution logic.
An ordered statement list says a step comes second. It does not say a step runs only if a condition held, or repeatedly, or not at all on some path. That is what a plan is, and it is what an ordered body cannot express.
Edges are typed by the reason control moves, so a query asks "which steps run when this test is true" rather than reconstructing it from nesting:
============ ===========================================================
next unconditional sequence
when_true the test held
when_false the test did not hold, including falling past an if
each_item one pass of a for
exhausted the iterable ran out
repeat the back edge closing a loop
============ ===========================================================
Joining with a trace answers the other half of the same profile's obligation, what actually ran, because both sides are keyed by source span.
analyze(source, *, module='', file='<source>')
¶
Return the control-flow graph of one module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
module
|
str
|
Its dotted import path, used to mint step identities. |
''
|
file
|
str
|
A label for spans. |
'<source>'
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Each function gets its own subgraph, since control does not flow between them without a call. Steps are statements rather than expressions: that is the granularity a plan is written at, and it keeps the graph small enough to render.
A span is on every step, so a trace joins to this graph without any further machinery.
Source code in src\awl\controlflow.py
as_document(graph, *, spans=True)
¶
Turn an analysed graph into JSON-LD nodes with typed edge predicates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
dict
|
The output of :func: |
required |
spans
|
bool
|
Locate each step. Turned off when the tree is in the same document and already names its statements with these identities: the step and the statement are then one node, and it would carry the same four numbers twice, once from each side. |
True
|
Returns:
| Type | Description |
|---|---|
dict
|
A |
Notes
Reifying an edge as its own node would need two joins to cross one edge
and would put repeat and when_true behind a literal comparison. A
predicate per reason keeps a path expression usable, which is what makes
"every step reachable while this test holds" a one-line query.
Source code in src\awl\controlflow.py
Projection¶
Build the JSON-LD context that projects an AstDoc to correct RDF.
The cheapest lever in the design: it retargets the whole projection without
touching the walker. Most of the defects measured in the previous hand-written
context were context work, not walker work. On the running example that context
rendered a collapsed constructor as an empty blank node, because
target_voltage and c_rate were not terms in it and JSON-LD drops
unmapped terms.
Two halves, and they are built differently on purpose.
The static half is the AST vocabulary. It is not written here at all: it
lives in ast-doc.schema.json, which is one file that is both the shape and
the mapping, so the terms are defined once. Its property names are chosen
(when_true reads as awl:whenTrue), which is precisely what @vocab
cannot derive, so each carries an @id.
The dynamic half is one term per class, scoped to itself. A class's field
names are the author's, carried verbatim, so @vocab yields exactly the
right IRI and a field that needs no coercion costs no term at all. Where the
class declares its own context, that is pulled rather than derived.
build_context(types=None)
¶
Build the document-level context.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
types
|
list of dict
|
|
None
|
Returns:
| Type | Description |
|---|---|
dict
|
Conforms to |
Notes
Ordered statement lists get @container: @list, which JSON-LD 1.1 API
section 8.3 defines as producing an RDF collection. That yields members
rather than positions: recovering an index means counting rdf:rest
hops, and SPARQL 1.1 property paths have only *, + and ?. So
order is emitted as a materialized integer alongside, and it is the
query surface.
Two alternatives are ruled out rather than overlooked. JSON-LD index maps
are defined for keys that have "no semantic meaning", which disqualifies
them by the specification's own framing, and SHACL sh:order is
non-validating form-layout metadata.
The numeric coercion guards a cross-language hazard. Python preserves
4.0 as a float, so it costs nothing here; JavaScript cannot, since
JSON.parse('4.0') === JSON.parse('4'). Without the coercion a voltage
written 4.0 silently becomes an integer on the way through a
JavaScript processor.
Source code in src\awl\context.py
declared_prefixes(info)
¶
Return the prefix bindings a class declares.
A term whose value is a plain string ending in a delimiter is a namespace, which is the same rule JSON-LD 1.1 uses to decide whether a term may be used as a prefix.
Source code in src\awl\context.py
declared_type_iris(info)
¶
Return the instance types a class declares, as the class means them.
The reference schemas write the type tag as a bare term and let the class's
own context say what it resolves to: type defaults to
["QuantityValue"] and the context maps QuantityValue to
qudt:QuantityValue. Read verbatim, that term would resolve against the
document instead and land back on the minted Python identity, which is the
one thing it is not: the class named an ontology class, and saying so is
the whole point of declaring it.
The minted identity is not replaced by it. Both are true of the instance and the graph carries both, because every other producer, from member writes to the def-use graph, joins on the minted one.
Source code in src\awl\context.py
type_terms(info)
¶
Return the terms that hold inside a node of this type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
info
|
dict
|
A |
required |
Returns:
| Type | Description |
|---|---|
dict
|
A JSON-LD context. Used both here, scoped under the class term, and by awl.collapse for the context it embeds in a standalone node, so the two cannot name one property two ways. |
Notes
The class namespace arrives as @vocab rather than as one term per
field, so a field that needs no coercion costs nothing at all. What is
left is only what @vocab cannot express: a declared term, a datatype,
or a reference.
A type-scoped context does not propagate to nested node objects (JSON-LD
1.1, 4.1.9), which is what keeps a plain Call nested inside a collapsed
node in the AST vocabulary instead of dragging it into the class namespace.
Source code in src\awl\context.py
Project a document to RDF, and read it back where that is honestly possible.
Export attaches a context and materializes schema-declared instance types, per the OO-LD rule that tooling exporting an instance must do so: a JSON-LD-only consumer sees the instance and its context but never the schema.
Import is scoped, and the scope is a refusal. The reduced profiles elide by construction, so no importer can recover what was dropped, and a silent partial reconstruction is worse than an error because the caller cannot tell which one they received.
A triple carries no attributes, so a confidence tier cannot ride on an edge. Named graphs carry it instead. That is not optional once meaning can be retrofitted by a model: a pipeline that cannot mark its own output is worse than no pipeline.
from_graph(graph, *, profile='ast')
¶
Reconstruct a document from RDF.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Graph
|
|
required |
profile
|
str
|
The profile the graph was produced at. |
'ast'
|
Returns:
| Type | Description |
|---|---|
Any
|
The expanded JSON-LD form. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If profile elides, because the dropped nodes are unrecoverable and a partial reconstruction would be indistinguishable from a complete one. |
Notes
Two preconditions for a faithful round trip are met and two are not.
Ordering survives, verified: @container: @list produces an
rdf:List. Unmapped keys survive, verified: an embedded @vocab keeps
them. Numeric fidelity on the way back is not verified, and precision
loss was measured in the other direction. Recovering the exact tree shape
needs JSON-LD framing, which is not implemented here.
So this returns the expanded form, not a document identical to the input. The authoritative round trip remains document to source, never RDF to source.
Source code in src\awl\rdf.py
to_dataset(doc, *, confidence='EXTRACTED', context=None)
¶
Return an rdflib Dataset with the statements in a tier-named graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
An |
required |
confidence
|
str
|
One of the keys of :data: |
'EXTRACTED'
|
context
|
dict
|
A |
None
|
Returns:
| Type | Description |
|---|---|
Dataset
|
|
Raises:
| Type | Description |
|---|---|
KeyError
|
If the tier is unknown, rather than silently defaulting to
|
Source code in src\awl\rdf.py
to_graph(doc, *, context=None)
¶
Return an rdflib Graph for doc.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
An |
required |
context
|
dict
|
A |
None
|
Returns:
| Type | Description |
|---|---|
Graph
|
|
Source code in src\awl\rdf.py
to_jsonld(doc, *, context=None)
¶
Attach a context to a document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
An |
required |
context
|
dict
|
A |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
A JSON-LD document. A collapsed node keeps its own embedded context, which is what maps its fields to the type's namespace rather than to this vocabulary. |
Source code in src\awl\rdf.py
Editing and execution¶
The headless editing model: palette, edits, and domain validation.
Pure functions over a compact document. No browser, no rendering. Every edit returns a new document and a patch that can be applied to source, so the visual and textual representations never diverge: an edit that returned only the new document would force the writer to diff two trees and guess which source range changed.
add_step(doc, *, into, node)
¶
Append a step to a statement list.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
A compact document. Not mutated. |
required |
into
|
list
|
Path to the statement list, e.g. |
required |
node
|
dict
|
The compact node to append. |
required |
Returns:
| Type | Description |
|---|---|
tuple
|
The new document with |
Notes
A statement list that is empty is not in the document at all:
:func:awl.compact.encode drops an empty required list, so an if with
no else carries no orelse key. Appending is the edit that gives it
one, and this is where that belongs: an editor that created the key itself
would be writing the encoding by hand, and one that did not raised
KeyError: 'orelse' on the first statement dropped into an empty branch.
Source code in src\awl\editor.py
delete_step(doc, *, path)
¶
Remove a step, renumbering the rest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
A compact document. Not mutated. |
required |
path
|
list
|
Path to the step, ending in its index, e.g. |
required |
Returns:
| Type | Description |
|---|---|
tuple
|
The new document and a structural patch. |
Source code in src\awl\editor.py
palette(schema, type_schemas=None)
¶
Derive the placeable node types.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
dict
|
The workflow domain schema. Its |
required |
type_schemas
|
list of dict
|
OO-LD class schemas. Each contributes a typed entry carrying the fields
its form needs, so a |
None
|
Returns:
| Type | Description |
|---|---|
list of dict
|
Each has |
Notes
Read from ordinary JSON Schema enum constraints, with no vendor
keyword. A document here is the syntax tree as JSON, so plain JSON Schema
already expresses the restriction. That matters twice over: any standard
validator enforces it, where a vendor keyword would have to be ignored by
a generic validator and would turn the constraint into a hint; and the
schema stays one artefact rather than encoding the same rule twice.
The two sources are both needed. The domain schema alone gives a list of names; the class schemas are what make an entry usable as a form.
Source code in src\awl\editor.py
reorder(doc, *, path, frm, to)
¶
Move a step within a statement list.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
A compact document. Not mutated. |
required |
path
|
list
|
Path to the statement list. |
required |
frm
|
int
|
Source and destination positions. |
required |
to
|
int
|
Source and destination positions. |
required |
Returns:
| Type | Description |
|---|---|
tuple
|
The new document with |
Source code in src\awl\editor.py
set_literal(doc, *, path, value, span)
¶
Replace a literal, returning the new document and a source patch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc
|
dict
|
A compact document. Not mutated. |
required |
path
|
list
|
Keys and indices locating the literal node. |
required |
value
|
Any
|
The new literal value. |
required |
span
|
dict
|
|
required |
Returns:
| Type | Description |
|---|---|
tuple
|
The new document, and a patch that can be applied by span rather than by regenerating the file. |
Source code in src\awl\editor.py
validate_domain(node, schema)
¶
Check one node against the workflow domain schema.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node
|
dict
|
The node to check. |
required |
schema
|
dict
|
The workflow domain schema. |
required |
Raises:
| Type | Description |
|---|---|
ValidationError
|
If the node is not permitted. Deliberately the library's own error and not a wrapped one, so the message names the offending enum. |
Source code in src\awl\editor.py
Apply editor changes back to source without disturbing untouched text.
Two tiers. A value edit patches the original text by source span and is
byte-exact everywhere else. A structural edit goes through libcst, which
attaches comments and whitespace to named slots so trivia travels with the node
it belongs to.
Regenerating the file from the AST is not an option for the retrofit path.
ast.parse has no comment node at all, so ast.unparse discards every
comment and reflows the layout. For workflows authored in the editor that is
irrelevant; for editing a laboratory's existing procedure files it is fatal,
and those are exactly the files this path targets.
apply_edits(source, edits)
¶
Apply non-overlapping span edits to source.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
Original text. |
required |
edits
|
list of dict
|
Each has |
required |
Returns:
| Type | Description |
|---|---|
str
|
The patched source. Every character outside an edited span is identical to the input: this is not a formatter and must not reflow, reindent or normalise quotes. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If two edits overlap, which would make the result order-dependent. |
Notes
Edits are applied right to left so earlier offsets stay valid.
Source code in src\awl\writeback.py
comment_text(note, where, indent)
¶
Return what a note edit writes into the range :func:trivia reported.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
note
|
str
|
What the reader typed, with or without a |
required |
where
|
str
|
|
required |
indent
|
int
|
The statement's own column, for a note written on a line of its own. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The replacement text, empty when the note was cleared. |
Source code in src\awl\writeback.py
comments(source)
¶
Return every comment in source, by the line it is on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Line number to |
Notes
The tokenizer rather than a regular expression, because a # inside a
string is not a comment and telling the two apart is what a tokenizer is
for. Half-written source tokenizes as far as it gets and the rest is
dropped, which is the same bargain an editor's source pane makes: a file
that does not parse changes nothing.
Source code in src\awl\writeback.py
insert_statement(source, *, into, code, leading_comment=None)
¶
Append a statement to a control-structure body, preserving trivia.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
Original text. |
required |
into
|
str
|
The control structure to insert into. Only |
required |
code
|
str
|
The statement's expression source, e.g. |
required |
leading_comment
|
str or None
|
A comment line emitted above the new statement, including its |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The patched source, with every pre-existing comment intact. |
Raises:
| Type | Description |
|---|---|
NotImplementedError
|
If into names a structure with no insertion rule, rather than silently returning the source unchanged. |
Notes
Span splicing cannot do this: it has no opinion about which comment belongs to which statement, so a moved node cannot carry its own. libcst attaches trivia to named slots and can.
Source code in src\awl\writeback.py
offsets(source, span)
¶
Return the character offsets of a document span.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The original text. |
required |
span
|
list of int
|
|
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Two coordinate systems meet here and neither can be dropped. A document
locates a node by line and column, because that is what a parser reports
and what survives an edit elsewhere in the file. A patch has to name
character offsets, because that is the only way to replace a range without
reflowing anything around it. Columns are byte-free: Python reports
col_offset in UTF-8 bytes on some paths and in characters here, and
this uses the character reading the document was built with.
Source code in src\awl\writeback.py
span_of(source, predicate)
¶
Return the character span of the first AST node satisfying predicate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The original text. |
required |
predicate
|
callable
|
Receives an :class: |
required |
Returns:
| Type | Description |
|---|---|
tuple of int
|
|
Raises:
| Type | Description |
|---|---|
LookupError
|
If no node matches, so a silent no-op edit is impossible. |
Notes
A keyword node spans the whole name=value pair, so an editor
patching a value must target the inner node or it overwrites the
parameter name too.
Source code in src\awl\writeback.py
trivia(source, span)
¶
Return the comment the statement at span carries, and the range an edit rewrites.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
The module's text. |
required |
span
|
list or None
|
|
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Here rather than in an editor, because the syntax tree has no comment node
and every canvas that wants one would otherwise tokenize the source itself.
The write half needs nothing new: the range this returns goes to
:func:apply_edits like any other span patch, so a note survives being
edited and the rest of the file does not move.
Two places, and only two. A comment beside the statement, on the line it starts on, is unambiguously about it. One above it, on the line before at the statement's own indentation, usually is.
Only the line immediately above, never a run of them. Nothing here can tell a banner from commented-out code, and claiming a four line block as one statement's explanation would put dead code into an input that writes it back as prose. A run keeps its lines as trivia and the nearest one is the note, which is a rule rather than an answer: there is no answer without a concrete-syntax tree.
Source code in src\awl\writeback.py
Runtime tracing: what ran, along which branch, for how many iterations.
Uses sys.settrace with opcode tracing for expression-level position, plus
an interleaved sys.setprofile to name C callees the static analysis cannot
see. Both hooks are independent slots and can be active at once, which is what
lets a C call be attributed to a source position.
Only one trace function and one profile function exist per thread, so this
conflicts with debuggers, coverage and profilers. The previous hooks are
saved and restored rather than cleared, so a surrounding coverage run survives,
but the two cannot observe the same code at the same time.
One consequence to read correctly: CPython does not trace the trace function itself, so every line reached from inside the callback is invisible to any coverage tool, and this module reports a low percentage no matter how well it is tested. The span algebra is therefore also exercised directly, outside the callback, where measurement works.
trace(fn, *, capture_c_calls=True, seconds=None)
¶
Run fn under instrumentation and return the events it produced.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
callable
|
Invoked with no arguments. |
required |
capture_c_calls
|
bool
|
Also install a profile hook, so C callees are named. |
True
|
seconds
|
float
|
Give up after this long, raising :class: An editor places loops, so it runs code nobody has read: a palette
template of |
None
|
Returns:
| Type | Description |
|---|---|
list of dict
|
Each conforms to |
Notes
Overhead is large: f_trace_opcodes fires once per instruction. This is
the mechanism for inspecting a run, not for production. sys.monitoring
with only BRANCH and CALL enabled is the cheap alternative, at the
cost of the frame object and therefore of locals.
Threads need threading.settrace separately; that is not handled here.
Source code in src\awl\trace.py
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 | |
Join a recorded run onto the plan: what actually ran, and how often.
The plan says a step may run, under a condition, possibly repeatedly. A trace says what happened. Both are keyed by source span, so the join needs no extra machinery and no instrumentation of the plan.
What this makes answerable, and what nothing in the surveyed prior art records: which arm of a branch was taken on a given run, and how many times a loop body actually executed. Static workflow languages omit branching, orchestrators expand it away before recording, and systems with real control flow keep the branch and iteration logic out of their provenance graphs.
A step that never ran is reported as such rather than omitted. "Present in the plan and absent from the run" is an answer; a missing node is not.
join(plan, events)
¶
Attribute trace events to plan steps.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
plan
|
dict
|
A control-flow graph, as returned by |
required |
events
|
list of dict
|
|
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Events are matched to steps by exact span first and by line second. The tracer reports expression-level positions, so an event usually falls inside a statement rather than on it; without the line-level fallback almost nothing would match, which would look like a run that did nothing.