openscript 0.4.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.
- openscript/__init__.py +40 -0
- openscript/__main__.py +62 -0
- openscript/accounting/__init__.py +74 -0
- openscript/accounting/analysis.py +174 -0
- openscript/accounting/charges.py +397 -0
- openscript/accounting/equity.py +234 -0
- openscript/accounting/report.py +82 -0
- openscript/accounting/shapes.py +74 -0
- openscript/accounting/statistics.py +300 -0
- openscript/accounting/trades.py +294 -0
- openscript/adapter/__init__.py +32 -0
- openscript/adapter/answers.py +215 -0
- openscript/adapter/channels.py +137 -0
- openscript/adapter/expectations.py +67 -0
- openscript/adapter/facts.py +127 -0
- openscript/adapter/matching.py +257 -0
- openscript/adapter/ordering.py +187 -0
- openscript/adapter/page.py +130 -0
- openscript/adapter/reading.py +357 -0
- openscript/adapter/reporting.py +244 -0
- openscript/adapter/running.py +449 -0
- openscript/adapter/serving.py +229 -0
- openscript/adapter/sessions.py +168 -0
- openscript/adapter/spellings.py +184 -0
- openscript/bars.py +157 -0
- openscript/budget.py +342 -0
- openscript/canonical.py +192 -0
- openscript/civil.py +196 -0
- openscript/contracts.py +165 -0
- openscript/dates.py +302 -0
- openscript/diagnostics.py +104 -0
- openscript/hours.py +165 -0
- openscript/inputs.py +239 -0
- openscript/intervals.py +60 -0
- openscript/library/__init__.py +76 -0
- openscript/library/arithmetic.py +128 -0
- openscript/library/averages.py +133 -0
- openscript/library/bars.py +60 -0
- openscript/library/bookkeeping.py +166 -0
- openscript/library/code_points.py +85 -0
- openscript/library/colour.py +202 -0
- openscript/library/composites.py +208 -0
- openscript/library/counting.py +218 -0
- openscript/library/deviation.py +155 -0
- openscript/library/elementary.py +206 -0
- openscript/library/extremes.py +122 -0
- openscript/library/flows.py +220 -0
- openscript/library/momentum.py +203 -0
- openscript/library/number_text.py +223 -0
- openscript/library/prices.py +36 -0
- openscript/library/ranges.py +105 -0
- openscript/library/rounding.py +123 -0
- openscript/library/series.py +213 -0
- openscript/library/stateful.py +442 -0
- openscript/library/stateless.py +261 -0
- openscript/library/strength.py +180 -0
- openscript/library/strings.py +228 -0
- openscript/library/trend.py +260 -0
- openscript/library/values.py +91 -0
- openscript/logbook.py +119 -0
- openscript/machine.py +499 -0
- openscript/memory.py +204 -0
- openscript/opcodes.py +166 -0
- openscript/program.py +146 -0
- openscript/run.py +368 -0
- openscript/strategy/__init__.py +78 -0
- openscript/strategy/calls.py +201 -0
- openscript/strategy/closable.py +182 -0
- openscript/strategy/fills.py +131 -0
- openscript/strategy/holdings.py +277 -0
- openscript/strategy/intents.py +162 -0
- openscript/strategy/ledger.py +270 -0
- openscript/strategy/placing.py +206 -0
- openscript/strategy/positions.py +124 -0
- openscript/strategy/refusals.py +293 -0
- openscript/strategy/rows.py +219 -0
- openscript/strategy/sizing.py +229 -0
- openscript/strategy/statuses.py +65 -0
- openscript/surface/__init__.py +115 -0
- openscript/surface/bands.py +103 -0
- openscript/surface/levels.py +44 -0
- openscript/surface/marks.py +52 -0
- openscript/surface/paints.py +58 -0
- openscript/surface/plots.py +44 -0
- openscript/surface/published.py +119 -0
- openscript/values.py +210 -0
- openscript/verify.py +301 -0
- openscript/verify_code.py +290 -0
- openscript/verify_requests.py +271 -0
- openscript/verify_shape.py +162 -0
- openscript/verify_tables.py +256 -0
- openscript/version.py +39 -0
- openscript/zones.py +118 -0
- openscript-0.4.0.dist-info/METADATA +82 -0
- openscript-0.4.0.dist-info/RECORD +97 -0
- openscript-0.4.0.dist-info/WHEEL +5 -0
- openscript-0.4.0.dist-info/top_level.txt +1 -0
openscript/budget.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""The counters that make a runaway script stop, and the ceilings a host sets.
|
|
2
|
+
|
|
3
|
+
The engine owns the loop, so every one of these is a number it increments rather
|
|
4
|
+
than something it hopes about. A platform running many customers' scripts in one
|
|
5
|
+
process needs exactly that: exceeding a budget is a diagnostic on the script that
|
|
6
|
+
did it, never a hang and never a frozen tab.
|
|
7
|
+
|
|
8
|
+
**Two kinds of budget, and the difference matters.**
|
|
9
|
+
|
|
10
|
+
The *loop budget* is part of the language. Section 8.5 requires two engines to
|
|
11
|
+
fail at the same iteration of the same loop on the same bar, so it counts
|
|
12
|
+
``TICK`` executions and nothing else, it comes from the program's own ``limits()``
|
|
13
|
+
line, and it is identical on every engine.
|
|
14
|
+
|
|
15
|
+
The *host ceilings* are not part of the language and cannot be. They exist so
|
|
16
|
+
that one script cannot take a process down, and a host that sets one accepts
|
|
17
|
+
that a program refused here may run elsewhere. None of them silently caps
|
|
18
|
+
anything: section 2.4 is explicit that a host refuses at load, naming the limit
|
|
19
|
+
and the value it allows, because a program that quietly gets a smaller budget
|
|
20
|
+
than it asked for produces a wrong number instead of a message and nothing on
|
|
21
|
+
the chart would say which number.
|
|
22
|
+
|
|
23
|
+
**There is no wall clock here.** A clock measures the machine rather than the
|
|
24
|
+
program, so a default one would make the same script pass on a fast machine and
|
|
25
|
+
fail on a slow one with nobody having asked for that trade. The step ceiling
|
|
26
|
+
below does the work a clock would otherwise do, and it is derived from the
|
|
27
|
+
program rather than chosen.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
33
|
+
|
|
34
|
+
from .diagnostics import Diagnostic, Position, failure, raise_at
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class EngineLimits:
|
|
39
|
+
"""What this engine allows, with ``None`` meaning no ceiling of its own."""
|
|
40
|
+
|
|
41
|
+
#: Call frames, OS5005 at load.
|
|
42
|
+
frames: int = 64
|
|
43
|
+
#: Code points one string may hold, OS5008.
|
|
44
|
+
#:
|
|
45
|
+
#: No document fixes this number: ``errors.md`` OS5008 states that there is a
|
|
46
|
+
#: ceiling and carries it in the message, and a host that sets one accepts
|
|
47
|
+
#: that a script refused here runs elsewhere. What a number left open does
|
|
48
|
+
#: not excuse is two engines choosing differently, because a case that
|
|
49
|
+
#: reaches the ceiling would then be refused by one of them and run by the
|
|
50
|
+
#: other, and section 10 calls a difference on a channel both answer a
|
|
51
|
+
#: release blocker. So this is the first engine's number, and the day either
|
|
52
|
+
#: moves the other moves with it.
|
|
53
|
+
string_length: int = 100_000
|
|
54
|
+
#: The most a program's own ``limits(loops = ...)`` may ask for, OS5003.
|
|
55
|
+
loops: Optional[int] = None
|
|
56
|
+
#: The most a program's own ``limits(history = ...)`` may ask for, OS5003.
|
|
57
|
+
history: Optional[int] = None
|
|
58
|
+
#: Instructions in the whole program, OS5009 at load.
|
|
59
|
+
instructions: Optional[int] = None
|
|
60
|
+
#: State regions, OS5004 at load.
|
|
61
|
+
states: Optional[int] = None
|
|
62
|
+
#: Reads one file may make, OS5006 at load.
|
|
63
|
+
requests: Optional[int] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DEFAULT_LIMITS = EngineLimits()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def all_code(program: Any) -> List[Sequence[Any]]:
|
|
70
|
+
"""Every instruction list in the program, the bodies of its reads included.
|
|
71
|
+
|
|
72
|
+
A read's expression is part of the program: an engine that cannot run an
|
|
73
|
+
``ELEM`` cannot run one inside a read either, and a budget or a tag worked
|
|
74
|
+
out from the bar's list alone would say otherwise.
|
|
75
|
+
"""
|
|
76
|
+
lists: List[Sequence[Any]] = [program["code"]]
|
|
77
|
+
lists.extend(one["code"] for one in program["functions"])
|
|
78
|
+
|
|
79
|
+
def walk(requests: Sequence[Any]) -> None:
|
|
80
|
+
for request in requests:
|
|
81
|
+
body = request["body"]
|
|
82
|
+
lists.append(body["code"])
|
|
83
|
+
lists.extend(one["code"] for one in body["functions"])
|
|
84
|
+
walk(body["requests"])
|
|
85
|
+
|
|
86
|
+
walk(program["requests"])
|
|
87
|
+
return lists
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _opcode_of(instruction: Any) -> Optional[str]:
|
|
91
|
+
"""The opcode of one element of a list, or nothing when it is not an instruction.
|
|
92
|
+
|
|
93
|
+
The budget refusals are step 7 of section 9.4 and verification is step 8, so
|
|
94
|
+
the walks here read lists that nothing has yet proved are instruction lists.
|
|
95
|
+
Reading them defensively is what keeps the order from turning a malformed
|
|
96
|
+
program into a thrown error rather than the OS6018 step 8 is about to report.
|
|
97
|
+
"""
|
|
98
|
+
return instruction[0] if isinstance(instruction, list) and instruction else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _count_requests(requests: Sequence[Any]) -> int:
|
|
102
|
+
found = 0
|
|
103
|
+
for one in requests:
|
|
104
|
+
found += 1 + _count_requests(one["body"]["requests"])
|
|
105
|
+
return found
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sites_in(code: Sequence[Any]) -> List[int]:
|
|
109
|
+
return [
|
|
110
|
+
one[1]
|
|
111
|
+
for one in code
|
|
112
|
+
if _opcode_of(one) == "CALL_FN" and len(one) > 1 and isinstance(one[1], int)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def call_depth(program: Any) -> int:
|
|
117
|
+
"""The deepest chain of call sites, which is the deepest the frame stack goes.
|
|
118
|
+
|
|
119
|
+
Recursion is an error in the language, so the call graph is acyclic and the
|
|
120
|
+
walk terminates; the guard below is against a program written by something
|
|
121
|
+
other than a conforming compiler, and it stops rather than looping forever.
|
|
122
|
+
"""
|
|
123
|
+
bodies = [one["code"] for one in program["functions"]]
|
|
124
|
+
known: Dict[int, int] = {}
|
|
125
|
+
walking: set = set()
|
|
126
|
+
|
|
127
|
+
def depth_of(site: int) -> int:
|
|
128
|
+
if site in known:
|
|
129
|
+
return known[site]
|
|
130
|
+
if site in walking:
|
|
131
|
+
return 1
|
|
132
|
+
walking.add(site)
|
|
133
|
+
entry = program["callSites"][site] if site < len(program["callSites"]) else None
|
|
134
|
+
body = bodies[entry["fn"]] if entry is not None and entry["fn"] < len(bodies) else []
|
|
135
|
+
deepest = 0
|
|
136
|
+
for inner in _sites_in(body):
|
|
137
|
+
deepest = max(deepest, depth_of(inner))
|
|
138
|
+
walking.discard(site)
|
|
139
|
+
known[site] = deepest + 1
|
|
140
|
+
return known[site]
|
|
141
|
+
|
|
142
|
+
deepest = 0
|
|
143
|
+
for site in _sites_in(program["code"]):
|
|
144
|
+
deepest = max(deepest, depth_of(site))
|
|
145
|
+
return deepest + 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _busiest_pair(program: Any) -> Tuple[str, str]:
|
|
149
|
+
"""The two functions whose nesting produced the most call paths.
|
|
150
|
+
|
|
151
|
+
OS5004's message names them because the number of paths grows
|
|
152
|
+
multiplicatively where several functions each call the next more than once,
|
|
153
|
+
and a reader told only the total has nowhere to start.
|
|
154
|
+
"""
|
|
155
|
+
counts: Dict[str, int] = {}
|
|
156
|
+
listings = [("the top level", program["code"])]
|
|
157
|
+
listings.extend((one["name"], one["code"]) for one in program["functions"])
|
|
158
|
+
for caller, code in listings:
|
|
159
|
+
for instruction in code:
|
|
160
|
+
if _opcode_of(instruction) != "CALL_FN":
|
|
161
|
+
continue
|
|
162
|
+
site = instruction[1]
|
|
163
|
+
if not isinstance(site, int) or site >= len(program["callSites"]):
|
|
164
|
+
continue
|
|
165
|
+
which = program["callSites"][site]["fn"]
|
|
166
|
+
if which >= len(program["functions"]):
|
|
167
|
+
continue
|
|
168
|
+
callee = program["functions"][which]["name"]
|
|
169
|
+
key = f"{caller}\x00{callee}"
|
|
170
|
+
counts[key] = counts.get(key, 0) + 1
|
|
171
|
+
best = ("the top level", "the functions it calls")
|
|
172
|
+
most = 0
|
|
173
|
+
# Sorted before the walk, so the answer does not depend on a table's order.
|
|
174
|
+
for key in sorted(counts):
|
|
175
|
+
if counts[key] <= most:
|
|
176
|
+
continue
|
|
177
|
+
caller, callee = key.split("\x00")
|
|
178
|
+
best, most = (caller, callee), counts[key]
|
|
179
|
+
return best
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_budgets(program: Any, limits: EngineLimits) -> Optional[Diagnostic]:
|
|
183
|
+
"""Step 7 of section 9.4: each ceiling in turn, stopping at the first one over."""
|
|
184
|
+
asked = program["limits"]
|
|
185
|
+
if limits.loops is not None and asked["loops"] > limits.loops:
|
|
186
|
+
return failure("OS5003", option="loops", max=limits.loops, found=asked["loops"])
|
|
187
|
+
history = asked["history"]
|
|
188
|
+
if limits.history is not None and history is not None and history > limits.history:
|
|
189
|
+
return failure("OS5003", option="history", max=limits.history, found=history)
|
|
190
|
+
if limits.instructions is not None:
|
|
191
|
+
found = sum(len(code) for code in all_code(program))
|
|
192
|
+
if found > limits.instructions:
|
|
193
|
+
return failure("OS5009", found=found, max=limits.instructions)
|
|
194
|
+
if limits.states is not None and len(program["states"]) > limits.states:
|
|
195
|
+
first, second = _busiest_pair(program)
|
|
196
|
+
return failure(
|
|
197
|
+
"OS5004",
|
|
198
|
+
found=len(program["states"]),
|
|
199
|
+
max=limits.states,
|
|
200
|
+
first=first,
|
|
201
|
+
second=second,
|
|
202
|
+
)
|
|
203
|
+
if limits.requests is not None:
|
|
204
|
+
found = _count_requests(program["requests"])
|
|
205
|
+
if found > limits.requests:
|
|
206
|
+
return failure("OS5006", found=found, max=limits.requests)
|
|
207
|
+
depth = call_depth(program)
|
|
208
|
+
if depth > limits.frames:
|
|
209
|
+
return failure("OS5005", construct="a call", found=depth, max=limits.frames)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def step_bound(program: Any) -> int:
|
|
214
|
+
"""The most instructions one bar of this program can possibly execute.
|
|
215
|
+
|
|
216
|
+
Verification proves three things that together bound a bar: the target of
|
|
217
|
+
every backward jump is a ``TICK``, so no cycle runs without charging the loop
|
|
218
|
+
budget; recursion is an error, so the call graph is acyclic and each call
|
|
219
|
+
site's body runs at most once per acyclic segment; and the lists are finite.
|
|
220
|
+
So a segment between two ``TICK`` executions costs at most the program's
|
|
221
|
+
whole instruction count once, and there are at most ``limits.loops`` such
|
|
222
|
+
segments plus the one that ends at ``HALT``.
|
|
223
|
+
|
|
224
|
+
The number is generous by design. It is not a performance budget: it is the
|
|
225
|
+
proof that a bar terminates, turned into a counter so that a program which
|
|
226
|
+
somehow exceeds its own static bound stops instead of running forever.
|
|
227
|
+
"""
|
|
228
|
+
acyclic = len(program["code"])
|
|
229
|
+
for site in program["callSites"]:
|
|
230
|
+
acyclic += len(program["functions"][site["fn"]]["code"])
|
|
231
|
+
segments = max(program["limits"]["loops"], 0) + 1
|
|
232
|
+
return max(acyclic, 1) * segments + acyclic
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def suggest_budget(budget: int) -> int:
|
|
236
|
+
"""A budget with room to spare, for OS5001's fix line.
|
|
237
|
+
|
|
238
|
+
The catalogue asks for a budget that would have completed the bar, and the
|
|
239
|
+
engine cannot know one: it stopped the loop rather than finishing it. Twice
|
|
240
|
+
the budget, rounded to one significant figure so the number reads like
|
|
241
|
+
something a person would type, is the nearest honest thing, and the message
|
|
242
|
+
beside it says the first fix is the exit condition.
|
|
243
|
+
"""
|
|
244
|
+
doubled = max(budget * 2, 1000)
|
|
245
|
+
magnitude = 10 ** math.floor(math.log10(doubled))
|
|
246
|
+
return int(math.ceil(doubled / magnitude) * magnitude)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class Budget:
|
|
250
|
+
"""The per-bar counters, reset at step 3 of every execution of a bar.
|
|
251
|
+
|
|
252
|
+
Per bar rather than per loop so that ten sequential loops and one nested
|
|
253
|
+
loop are treated alike, and reset each bar so that a long dataset is never
|
|
254
|
+
itself a reason to fail.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(self, step_ceiling: int, loop_ceiling: int, string_ceiling: int) -> None:
|
|
258
|
+
self._step_ceiling = step_ceiling
|
|
259
|
+
self._loop_ceiling = loop_ceiling
|
|
260
|
+
self._string_ceiling = string_ceiling
|
|
261
|
+
self._loops = 0
|
|
262
|
+
self._steps = 0
|
|
263
|
+
|
|
264
|
+
def begin(self) -> None:
|
|
265
|
+
self._loops = 0
|
|
266
|
+
self._steps = 0
|
|
267
|
+
|
|
268
|
+
def step(self) -> bool:
|
|
269
|
+
"""One instruction executed, and whether the ceiling is now past.
|
|
270
|
+
|
|
271
|
+
Answers rather than raising, so the dispatch loop does the check inline
|
|
272
|
+
and the cost is one comparison on the path every instruction takes.
|
|
273
|
+
"""
|
|
274
|
+
self._steps += 1
|
|
275
|
+
return self._steps > self._step_ceiling
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def loops(self) -> int:
|
|
279
|
+
return self._loops
|
|
280
|
+
|
|
281
|
+
def tick(self, position: Position, line: int) -> None:
|
|
282
|
+
"""Section 5.5: one iteration of a loop charged to the per-bar budget."""
|
|
283
|
+
self._loops += 1
|
|
284
|
+
if self._loops > self._loop_ceiling:
|
|
285
|
+
self.spent(position, line)
|
|
286
|
+
|
|
287
|
+
def text(self, position: Position, built: str) -> str:
|
|
288
|
+
"""A string the bar built, against the ceiling, counted in code points.
|
|
289
|
+
|
|
290
|
+
`stdlib.md` section 10 counts a string in code points and not in the
|
|
291
|
+
storage unit of the language an engine happens to be written in, which on
|
|
292
|
+
this host is what a string already is, so the length is the count.
|
|
293
|
+
|
|
294
|
+
Answers the string so that a caller applies the ceiling in the expression
|
|
295
|
+
that produces the value, rather than in a line above it that is easy to
|
|
296
|
+
move away from the thing it guards.
|
|
297
|
+
"""
|
|
298
|
+
self.measured(position, len(built))
|
|
299
|
+
return built
|
|
300
|
+
|
|
301
|
+
def measured(self, position: Position, length: Optional[int]) -> None:
|
|
302
|
+
"""The same ceiling against a length nothing has built yet.
|
|
303
|
+
|
|
304
|
+
``None`` is a call the library cannot measure in advance, which is every
|
|
305
|
+
call but the two that can be asked for a string no engine could hold:
|
|
306
|
+
a repeat is a length times a count, and a fixed decimal conversion is one
|
|
307
|
+
character per decimal place asked for. Measuring those first is the
|
|
308
|
+
difference between reporting that a string is too long and running out of
|
|
309
|
+
memory finding out (`stdlib.md` section 10, on ``text(x, decimals)``).
|
|
310
|
+
|
|
311
|
+
The refusal is the run's and not the library's: nothing in that package
|
|
312
|
+
raises, because a wrong argument there is absence and a ceiling is the
|
|
313
|
+
engine's to spend.
|
|
314
|
+
"""
|
|
315
|
+
if length is not None and length > self._string_ceiling:
|
|
316
|
+
raise_at("OS5008", position, max=self._string_ceiling, found=length)
|
|
317
|
+
|
|
318
|
+
def spent(self, position: Position, line: Optional[int]) -> None:
|
|
319
|
+
"""The refusal, whichever of the two counters ran out.
|
|
320
|
+
|
|
321
|
+
The step ceiling is the static bound of ``step_bound`` and a loop is the
|
|
322
|
+
only construct that can approach it, so the loop that was running is the
|
|
323
|
+
one to name. A bar that passes it with no loop running contradicts what
|
|
324
|
+
verification proved about the program, which is OS6018's case.
|
|
325
|
+
"""
|
|
326
|
+
if line is None:
|
|
327
|
+
raise_at(
|
|
328
|
+
"OS6018",
|
|
329
|
+
position,
|
|
330
|
+
location=f"step {self._steps}",
|
|
331
|
+
reason=(
|
|
332
|
+
"the bar executed more instructions than the program can reach without a "
|
|
333
|
+
"loop, so the instruction list is not the one verification walked"
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
raise_at(
|
|
337
|
+
"OS5001",
|
|
338
|
+
position,
|
|
339
|
+
budget=self._loop_ceiling,
|
|
340
|
+
line=line,
|
|
341
|
+
suggested=suggest_budget(self._loop_ceiling),
|
|
342
|
+
)
|
openscript/canonical.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The canonical encoding, ``compiled-program.md`` section 2.14, and the door text comes in by.
|
|
2
|
+
|
|
3
|
+
A hash is taken over canonical bytes and a host records it against a chart, a
|
|
4
|
+
backtest run and a live process. So text that parses to a program but is spelled
|
|
5
|
+
some other way is text that hash does not name, and an engine that accepted it
|
|
6
|
+
would report a run under a name identifying different bytes. That is why
|
|
7
|
+
``load_text`` below parses, writes the result out again through the writer here,
|
|
8
|
+
and refuses any difference, naming the character the two part at.
|
|
9
|
+
|
|
10
|
+
**Canonicity is required of text and not of an object.** ``spec/decisions.md``
|
|
11
|
+
minute 57 settled it and section 9.4 step 1 carries the sentence: an object
|
|
12
|
+
built in the same process was never text, has nothing to be canonical about, and
|
|
13
|
+
enters the refusal order at step 2. The check lives at the text boundary and
|
|
14
|
+
nowhere else, so the in-process case is free of a rule it could not break.
|
|
15
|
+
|
|
16
|
+
The rules, none of them cosmetic and none of them this file's invention:
|
|
17
|
+
|
|
18
|
+
- UTF-8, no byte order mark, no whitespace between tokens.
|
|
19
|
+
- Object keys sorted ascending by Unicode code point, because a sort is a rule
|
|
20
|
+
an emitter in any language can follow without a table.
|
|
21
|
+
- A number written by the one rule ``language.md`` section 5.5 gives, which is
|
|
22
|
+
the same rule for every number that becomes text anywhere in the language.
|
|
23
|
+
- A string escaping only the quote, the backslash and the code points below
|
|
24
|
+
0x20, the last as ``\\u00XX`` except for the three that have a letter.
|
|
25
|
+
|
|
26
|
+
**The digits are the interpreter's and the layout is not.** The shortest decimal
|
|
27
|
+
digit string that reads back as the same binary64 is what every host worth
|
|
28
|
+
shipping on produces, so that half is taken from this one. Where two hosts part
|
|
29
|
+
company is the layout: when a value is written positionally and when with an
|
|
30
|
+
exponent, and how the exponent is spelled. That half is written out here from
|
|
31
|
+
the rule, once: the library's ``number_text`` calls this writer rather than
|
|
32
|
+
restating it, and ``spec/vectors/number-text.json`` holds the boundary cases
|
|
33
|
+
every engine checks itself against.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
from typing import Any, Tuple
|
|
39
|
+
|
|
40
|
+
from .diagnostics import Diagnostic, malformed
|
|
41
|
+
|
|
42
|
+
#: The two thresholds section 5.5 puts the positional form between.
|
|
43
|
+
_HIGHEST_POSITIONAL = 21
|
|
44
|
+
_LOWEST_POSITIONAL = -6
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def spread(shown: str, places: int = 0) -> Tuple[str, int]:
|
|
48
|
+
"""A written magnitude as digits, and where the point falls.
|
|
49
|
+
|
|
50
|
+
The value is ``0.d1d2...dk`` times ten to the returned power, which is the
|
|
51
|
+
shape 5.5 states the rule in. Whatever layout the writing arrived in is
|
|
52
|
+
undone here: the digits and the point are all that is kept, so the layout
|
|
53
|
+
below is free to be the language's rather than the host's. ``places`` moves
|
|
54
|
+
the point right, which is how the fixed decimal conversion scales without
|
|
55
|
+
multiplying a second time.
|
|
56
|
+
|
|
57
|
+
It takes the writing rather than the number because the two callers need two
|
|
58
|
+
different ones: which digits a magnitude has is the host's question, and
|
|
59
|
+
where they sit is this one's.
|
|
60
|
+
"""
|
|
61
|
+
marker = shown.find("e")
|
|
62
|
+
mantissa = shown if marker < 0 else shown[:marker]
|
|
63
|
+
exponent = 0 if marker < 0 else int(shown[marker + 1 :])
|
|
64
|
+
dot = mantissa.find(".")
|
|
65
|
+
whole = mantissa if dot < 0 else mantissa[:dot]
|
|
66
|
+
fraction = "" if dot < 0 else mantissa[dot + 1 :]
|
|
67
|
+
digits = whole + fraction
|
|
68
|
+
point = len(whole) + exponent + places
|
|
69
|
+
# A positional form below one carries leading zeros that are not digits of
|
|
70
|
+
# the value, and a whole number carries trailing zeros that are its layout.
|
|
71
|
+
while digits.startswith("0"):
|
|
72
|
+
digits = digits[1:]
|
|
73
|
+
point -= 1
|
|
74
|
+
while digits.endswith("0"):
|
|
75
|
+
digits = digits[:-1]
|
|
76
|
+
return digits, point
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _layout(digits: str, point: int) -> str:
|
|
80
|
+
"""Positional between the two thresholds, an exponent outside them."""
|
|
81
|
+
count = len(digits)
|
|
82
|
+
if count <= point <= _HIGHEST_POSITIONAL:
|
|
83
|
+
return digits + "0" * (point - count)
|
|
84
|
+
if 0 < point <= _HIGHEST_POSITIONAL:
|
|
85
|
+
return f"{digits[:point]}.{digits[point:]}"
|
|
86
|
+
if _LOWEST_POSITIONAL < point <= 0:
|
|
87
|
+
return f"0.{'0' * -point}{digits}"
|
|
88
|
+
exponent = point - 1
|
|
89
|
+
lead = digits if count == 1 else f"{digits[0]}.{digits[1:]}"
|
|
90
|
+
return f"{lead}e{'-' if exponent < 0 else ''}{abs(exponent)}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def canonical_number(value: float) -> str:
|
|
94
|
+
"""A number as text: the one writer.
|
|
95
|
+
|
|
96
|
+
Two engines compare numbers as bits, and they compare text in a case file,
|
|
97
|
+
an expected column, a table cell and ``text(x)``, so how a number becomes
|
|
98
|
+
text has to be one rule both implement and, here, one function both call.
|
|
99
|
+
|
|
100
|
+
The two callers are ``canonicalise`` below, which writes a constant into the
|
|
101
|
+
text a recorded hash is taken over, and ``library/number_text.py``, which is
|
|
102
|
+
``text(x)`` and the digits of ``text(x, decimals)``. The sentence above was
|
|
103
|
+
a claim rather than a fact for one stage: the rule was implemented twice,
|
|
104
|
+
with the two layout thresholds typed out in each, and the vectors held both
|
|
105
|
+
to the same answers, so nothing failed. What would have failed is the day
|
|
106
|
+
one of the two moved and a label stopped agreeing with a recorded hash.
|
|
107
|
+
"""
|
|
108
|
+
number = float(value)
|
|
109
|
+
if not math.isfinite(number):
|
|
110
|
+
raise ValueError("a compiled program holds finite numbers only")
|
|
111
|
+
# Zero and negative zero are one value to the language and one spelling here.
|
|
112
|
+
if number == 0:
|
|
113
|
+
return "0"
|
|
114
|
+
digits, point = spread(repr(abs(number)))
|
|
115
|
+
return ("-" if number < 0 else "") + _layout(digits, point)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_LETTERED = {"\n": "\\n", "\r": "\\r", "\t": "\\t", '"': '\\"', "\\": "\\\\"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def canonical_string(value: str) -> str:
|
|
122
|
+
out = ['"']
|
|
123
|
+
for character in value:
|
|
124
|
+
lettered = _LETTERED.get(character)
|
|
125
|
+
if lettered is not None:
|
|
126
|
+
out.append(lettered)
|
|
127
|
+
elif ord(character) < 0x20:
|
|
128
|
+
out.append(f"\\u{ord(character):04x}")
|
|
129
|
+
else:
|
|
130
|
+
out.append(character)
|
|
131
|
+
out.append('"')
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def canonicalise(value: Any) -> str:
|
|
136
|
+
"""One value of a parsed program, written the one way it may be written."""
|
|
137
|
+
if value is None:
|
|
138
|
+
return "null"
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return "true" if value else "false"
|
|
141
|
+
if isinstance(value, (int, float)):
|
|
142
|
+
return canonical_number(value)
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
return canonical_string(value)
|
|
145
|
+
if isinstance(value, (list, tuple)):
|
|
146
|
+
return "[" + ",".join(canonicalise(one) for one in value) + "]"
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
pairs = sorted(value.items(), key=lambda pair: pair[0])
|
|
149
|
+
body = ",".join(f"{canonical_string(key)}:{canonicalise(one)}" for key, one in pairs)
|
|
150
|
+
return "{" + body + "}"
|
|
151
|
+
raise ValueError(f"a compiled program holds no value of this kind: {type(value).__name__}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _refuse_constant(text: str) -> Any:
|
|
155
|
+
"""What a reader must not build: the three spellings outside binary64's finite range."""
|
|
156
|
+
raise ValueError(f"{text} is not a value a compiled program holds")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parted_at(text: str, written: str) -> int:
|
|
160
|
+
"""The index the two spellings part at, which is where the message points."""
|
|
161
|
+
shortest = min(len(text), len(written))
|
|
162
|
+
for index in range(shortest):
|
|
163
|
+
if text[index] != written[index]:
|
|
164
|
+
return index
|
|
165
|
+
return shortest
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse(text: str) -> Tuple[Any, Diagnostic]:
|
|
169
|
+
"""Step 1 of section 9.4: parse the text, and require it to be canonical.
|
|
170
|
+
|
|
171
|
+
Returns the parsed program and no diagnostic, or nothing and the refusal.
|
|
172
|
+
Both halves are OS6018 and each names where the text stopped being readable
|
|
173
|
+
or stopped being canonical, because neither is repairable by hand: a program
|
|
174
|
+
is written by a compiler and a malformed one is that compiler's defect.
|
|
175
|
+
"""
|
|
176
|
+
if text.startswith(""):
|
|
177
|
+
return None, malformed("character 0", "the text carries a byte order mark")
|
|
178
|
+
try:
|
|
179
|
+
parsed = json.loads(text, parse_constant=_refuse_constant)
|
|
180
|
+
except ValueError as reason:
|
|
181
|
+
return None, malformed("the encoding", f"the text is not readable: {reason}")
|
|
182
|
+
try:
|
|
183
|
+
written = canonicalise(parsed)
|
|
184
|
+
except ValueError as reason:
|
|
185
|
+
return None, malformed("the encoding", str(reason))
|
|
186
|
+
if written != text:
|
|
187
|
+
return None, malformed(
|
|
188
|
+
f"character {_parted_at(text, written)}",
|
|
189
|
+
"the text is not the canonical encoding of what it parses to, and the hash a host "
|
|
190
|
+
"records against a run is taken over canonical bytes",
|
|
191
|
+
)
|
|
192
|
+
return parsed, None
|