@reventlessdev/reventless-spec 3.0.0-alpha.100
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.
- package/CHANGELOG.md +931 -0
- package/LICENSE +202 -0
- package/README.md +109 -0
- package/package.json +49 -0
- package/rescript.json +32 -0
- package/run-generator.mjs +2 -0
- package/run-platform-generator.mjs +2 -0
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +192 -0
- package/src/LogPrefix.res.mjs +159 -0
- package/src/PackageVersion.res +67 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/CapabilityManifest.res +74 -0
- package/src/components/CapabilityManifest.res.mjs +61 -0
- package/src/components/ComponentKind.res +99 -0
- package/src/components/ComponentKind.res.mjs +125 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +118 -0
- package/src/components/DcbDecode.res.mjs +100 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +1335 -0
- package/src/components/DcbTag.res.mjs +898 -0
- package/src/components/DcbValidation.res +427 -0
- package/src/components/DcbValidation.res.mjs +423 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +85 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +153 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +538 -0
- package/src/components/Plugin.res.mjs +264 -0
- package/src/components/PluginName.res +39 -0
- package/src/components/PluginName.res.mjs +45 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +55 -0
- package/src/components/Reference.res.mjs +50 -0
- package/src/components/Snapshot.res +26 -0
- package/src/components/Snapshot.res.mjs +2 -0
- package/src/components/StateAnnotations.res +97 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +123 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +842 -0
- package/src/generator/Codegen.res.mjs +565 -0
- package/src/generator/Config.res +106 -0
- package/src/generator/Config.res.mjs +69 -0
- package/src/generator/Discovery.res +230 -0
- package/src/generator/Discovery.res.mjs +198 -0
- package/src/generator/Generator_Node.res +14 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +460 -0
- package/src/generator/Pairing.res.mjs +415 -0
- package/src/generator/PlatformCodegen.res +207 -0
- package/src/generator/PlatformCodegen.res.mjs +154 -0
- package/src/generator/PlatformGenerator.res +126 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/generator/PluginGenerator.res +57 -0
- package/src/generator/PluginGenerator.res.mjs +73 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Capabilities.res +43 -0
- package/src/semantic/Capabilities.res.mjs +17 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Geocoding.res +127 -0
- package/src/semantic/Geocoding.res.mjs +36 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Offload.res +294 -0
- package/src/semantic/Offload.res.mjs +191 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +162 -0
- package/src/semantic/Semantic.res.mjs +95 -0
- package/src/semantic/StorageRef.res +164 -0
- package/src/semantic/StorageRef.res.mjs +111 -0
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +86 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +326 -0
- package/src/types/Message.res.mjs +186 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
7
|
+
|
|
8
|
+
function validateLat(raw) {
|
|
9
|
+
if (isFinite(raw)) {
|
|
10
|
+
if (raw < -90.0 || raw > 90.0) {
|
|
11
|
+
return {
|
|
12
|
+
TAG: "Error",
|
|
13
|
+
_0: `a latitude runs from -90 to 90 degrees, got ` + raw.toString() + `. A value beyond ±90 is usually a longitude in the latitude's place.`
|
|
14
|
+
};
|
|
15
|
+
} else {
|
|
16
|
+
return {
|
|
17
|
+
TAG: "Ok",
|
|
18
|
+
_0: raw
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
return {
|
|
23
|
+
TAG: "Error",
|
|
24
|
+
_0: `a latitude must be a finite number of degrees, got ` + raw.toString()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateLng(raw) {
|
|
30
|
+
if (isFinite(raw)) {
|
|
31
|
+
if (raw < -180.0 || raw > 180.0) {
|
|
32
|
+
return {
|
|
33
|
+
TAG: "Error",
|
|
34
|
+
_0: `a longitude runs from -180 to 180 degrees, got ` + raw.toString()
|
|
35
|
+
};
|
|
36
|
+
} else {
|
|
37
|
+
return {
|
|
38
|
+
TAG: "Ok",
|
|
39
|
+
_0: raw
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
return {
|
|
44
|
+
TAG: "Error",
|
|
45
|
+
_0: `a longitude must be a finite number of degrees, got ` + raw.toString()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let latSchema = S.refine(S.float, s => (raw => {
|
|
51
|
+
let why = validateLat(raw);
|
|
52
|
+
if (why.TAG === "Ok") {
|
|
53
|
+
return;
|
|
54
|
+
} else {
|
|
55
|
+
return s.fail(why._0, undefined);
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
let lngSchema = S.refine(S.float, s => (raw => {
|
|
60
|
+
let why = validateLng(raw);
|
|
61
|
+
if (why.TAG === "Ok") {
|
|
62
|
+
return;
|
|
63
|
+
} else {
|
|
64
|
+
return s.fail(why._0, undefined);
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
let schema = S.schema(s => ({
|
|
69
|
+
lat: s.m(latSchema),
|
|
70
|
+
lng: s.m(lngSchema)
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.geoPoint, undefined);
|
|
74
|
+
|
|
75
|
+
function make(lat, lng) {
|
|
76
|
+
let match = validateLat(lat);
|
|
77
|
+
let match$1 = validateLng(lng);
|
|
78
|
+
if (match.TAG === "Ok") {
|
|
79
|
+
if (match$1.TAG === "Ok") {
|
|
80
|
+
return {
|
|
81
|
+
TAG: "Ok",
|
|
82
|
+
_0: {
|
|
83
|
+
lat: match._0,
|
|
84
|
+
lng: match$1._0
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
} else {
|
|
88
|
+
return {
|
|
89
|
+
TAG: "Error",
|
|
90
|
+
_0: match$1._0
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
return {
|
|
95
|
+
TAG: "Error",
|
|
96
|
+
_0: match._0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function format(p) {
|
|
102
|
+
return p.lat.toString() + `, ` + p.lng.toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toRadians(degrees) {
|
|
106
|
+
return degrees * Math.PI / 180.0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function distanceTo(a, b) {
|
|
110
|
+
let lat1 = toRadians(a.lat);
|
|
111
|
+
let lat2 = toRadians(b.lat);
|
|
112
|
+
let dLat = toRadians(b.lat - a.lat);
|
|
113
|
+
let dLng = toRadians(b.lng - a.lng);
|
|
114
|
+
let h = Math.sin(dLat / 2.0) * Math.sin(dLat / 2.0) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2.0) * Math.sin(dLng / 2.0);
|
|
115
|
+
return 2.0 * Math.asin(Math.sqrt(Math.min(1.0, h))) * 6371008.8;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toGeoJson(p) {
|
|
119
|
+
return Object.fromEntries([
|
|
120
|
+
[
|
|
121
|
+
"type",
|
|
122
|
+
"Point"
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
"coordinates",
|
|
126
|
+
[
|
|
127
|
+
p.lng,
|
|
128
|
+
p.lat
|
|
129
|
+
]
|
|
130
|
+
]
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fromGeoJson(json) {
|
|
135
|
+
let o = Stdlib_JSON.Decode.object(json);
|
|
136
|
+
if (o === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
TAG: "Error",
|
|
139
|
+
_0: "a GeoJSON point is an object"
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
let other = Stdlib_Option.flatMap(o["type"], Stdlib_JSON.Decode.string);
|
|
143
|
+
if (other === undefined) {
|
|
144
|
+
return {
|
|
145
|
+
TAG: "Error",
|
|
146
|
+
_0: "a GeoJSON geometry has a type"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (other !== "Point") {
|
|
150
|
+
return {
|
|
151
|
+
TAG: "Error",
|
|
152
|
+
_0: `expected a GeoJSON Point, got ` + other
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
let coords = Stdlib_Option.flatMap(o["coordinates"], Stdlib_JSON.Decode.array);
|
|
156
|
+
if (coords === undefined) {
|
|
157
|
+
return {
|
|
158
|
+
TAG: "Error",
|
|
159
|
+
_0: "a GeoJSON point has a coordinates array"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let match = Stdlib_Option.flatMap(coords[0], Stdlib_JSON.Decode.float);
|
|
163
|
+
let match$1 = Stdlib_Option.flatMap(coords[1], Stdlib_JSON.Decode.float);
|
|
164
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
165
|
+
return make(match$1, match);
|
|
166
|
+
} else {
|
|
167
|
+
return {
|
|
168
|
+
TAG: "Error",
|
|
169
|
+
_0: "a GeoJSON point's coordinates are two numbers, [lng, lat]"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let earthRadiusMetres = 6371008.8;
|
|
175
|
+
|
|
176
|
+
export {
|
|
177
|
+
validateLat,
|
|
178
|
+
validateLng,
|
|
179
|
+
latSchema,
|
|
180
|
+
lngSchema,
|
|
181
|
+
schema$1 as schema,
|
|
182
|
+
make,
|
|
183
|
+
format,
|
|
184
|
+
earthRadiusMetres,
|
|
185
|
+
toRadians,
|
|
186
|
+
distanceTo,
|
|
187
|
+
toGeoJson,
|
|
188
|
+
fromGeoJson,
|
|
189
|
+
}
|
|
190
|
+
/* latSchema Not a pure module */
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Turning an address into a point, and deciding whether to believe the answer.
|
|
3
|
+
|
|
4
|
+
The *transport* is provider-specific — Amazon Location, a self-hosted Nominatim,
|
|
5
|
+
a vendor's HTTP API — and lives with its provider. What lives here is everything
|
|
6
|
+
that is the same regardless of who answers: the shape of an answer, the two ways
|
|
7
|
+
a lookup can fail, and the rule for when a ranked list is confident enough to
|
|
8
|
+
write a coordinate into an event log.
|
|
9
|
+
|
|
10
|
+
That split matters because the confidence rule is the part that is easy to get
|
|
11
|
+
wrong and expensive to get wrong twice. A caller that re-derives it — takes
|
|
12
|
+
`results[0]` because the list was ranked — produces a plausible marker in the
|
|
13
|
+
wrong region, drawn without an error anywhere. Deciding it once, here, is what
|
|
14
|
+
stops each transport inventing its own answer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One candidate a geocoder returned. */
|
|
18
|
+
type candidate = {
|
|
19
|
+
/** The provider's canonical rendering of the address it matched. */
|
|
20
|
+
label: string,
|
|
21
|
+
point: GeoPoint.t,
|
|
22
|
+
/**
|
|
23
|
+
How well this result matches the query, 0…1.
|
|
24
|
+
|
|
25
|
+
`None` when the provider does not score its results. That is not the same as a
|
|
26
|
+
low score and must not be read as a high one — `confidentMatch` declines it,
|
|
27
|
+
because an unscored list cannot support an unattended decision.
|
|
28
|
+
*/
|
|
29
|
+
relevance: option<float>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
Why a lookup produced no usable point.
|
|
34
|
+
|
|
35
|
+
Two constructors rather than one message because the caller's retry decision
|
|
36
|
+
turns on exactly this distinction, and a `switch` is the only form of it that
|
|
37
|
+
cannot be got wrong. A translator that cannot tell "no such address" from "the
|
|
38
|
+
service is down" turns one outage into a permanent verdict on every address in
|
|
39
|
+
flight.
|
|
40
|
+
*/
|
|
41
|
+
type failure =
|
|
42
|
+
| /** The provider could not be reached, or refused the call. Retry. */
|
|
43
|
+
Unavailable(string)
|
|
44
|
+
| /** The provider answered, and had nothing for this text. Do not retry. */
|
|
45
|
+
NoMatch
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
The port a caller reaches a geocoder through.
|
|
49
|
+
|
|
50
|
+
Everything above says what an answer looks like; this says how one is asked for.
|
|
51
|
+
Written down as a type before anything is injected against it, so that swapping
|
|
52
|
+
the implementation underneath a caller is a change of *supplier* rather than a
|
|
53
|
+
change of call site: whatever eventually hands a translator its geocoder — an
|
|
54
|
+
HTTP client reading an endpoint out of its environment, a provider SDK called
|
|
55
|
+
directly — has to satisfy this, and the `await search(~text=…)` in the caller
|
|
56
|
+
does not move.
|
|
57
|
+
|
|
58
|
+
`~text` is the address as a human typed it, unnormalised. Normalising it is the
|
|
59
|
+
provider's job, and its canonical rendering comes back as `candidate.label`.
|
|
60
|
+
*/
|
|
61
|
+
type search = (~text: string) => promise<result<array<candidate>, failure>>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
The default confidence floor, calibrated against Amazon Location's Esri index.
|
|
65
|
+
|
|
66
|
+
Measured, not guessed — and the measurement moved it a long way. Esri does not
|
|
67
|
+
spread its scores over 0…1: everything it is willing to return at all lands in
|
|
68
|
+
roughly 0.9…1.0, so a floor of `0.8` admitted almost every answer and left the
|
|
69
|
+
ambiguity margin doing all the work. The separation is up at the top of the
|
|
70
|
+
range instead — in a 24-address corpus the correct pinpoint matches scored
|
|
71
|
+
≥ 0.988 while the wrong ones (a misspelling resolved to the wrong state, a
|
|
72
|
+
street name matched to a different street in the right city) scored 0.913…0.958.
|
|
73
|
+
|
|
74
|
+
`0.97` sits in that gap. The wider consequence is that these numbers are
|
|
75
|
+
*provider-calibrated* even though everything else in this module is
|
|
76
|
+
provider-neutral, which is exactly why both are labelled arguments: a geocoder
|
|
77
|
+
that scores on a different curve needs its own pair, and the defaults are the
|
|
78
|
+
Esri answer rather than a universal one.
|
|
79
|
+
*/
|
|
80
|
+
let defaultMinRelevance = 0.97
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
How close the runner-up may come before the answer is called ambiguous.
|
|
84
|
+
|
|
85
|
+
Deliberately small, for the same reason the floor is large. Genuine ambiguity in
|
|
86
|
+
Esri shows up as a *tie* — "Springfield" returns five states at exactly 1.0,
|
|
87
|
+
"221B Baker" five towns at exactly 0.8222 — not as a near miss. A wide margin
|
|
88
|
+
does not catch more of those; it only starts rejecting clear winners, because a
|
|
89
|
+
correct match at 0.991 routinely has a plausible runner-up at 0.962.
|
|
90
|
+
*/
|
|
91
|
+
let defaultAmbiguityMargin = 0.01
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
The one candidate confident enough to store, or `None`.
|
|
95
|
+
|
|
96
|
+
Two ways to be unsure, and both decline:
|
|
97
|
+
|
|
98
|
+
- the top candidate scores below `minRelevance` — the provider matched
|
|
99
|
+
something, loosely;
|
|
100
|
+
- the runner-up scores within `ambiguityMargin` of the top — the provider
|
|
101
|
+
matched several things about equally well, which is what a bare town name
|
|
102
|
+
does.
|
|
103
|
+
|
|
104
|
+
`None` means "ask a human", not "no result". The candidates are still there for
|
|
105
|
+
a caller that wants to show them.
|
|
106
|
+
*/
|
|
107
|
+
let confidentMatch = (
|
|
108
|
+
candidates: array<candidate>,
|
|
109
|
+
~minRelevance: float=defaultMinRelevance,
|
|
110
|
+
~ambiguityMargin: float=defaultAmbiguityMargin,
|
|
111
|
+
): option<candidate> =>
|
|
112
|
+
switch candidates->Array.get(0) {
|
|
113
|
+
| None => None
|
|
114
|
+
| Some(top) =>
|
|
115
|
+
switch top.relevance {
|
|
116
|
+
| None => None
|
|
117
|
+
| Some(topScore) =>
|
|
118
|
+
if topScore < minRelevance {
|
|
119
|
+
None
|
|
120
|
+
} else {
|
|
121
|
+
switch candidates->Array.get(1)->Option.flatMap(c => c.relevance) {
|
|
122
|
+
| Some(runnerUp) if topScore -. runnerUp < ambiguityMargin => None
|
|
123
|
+
| _ => Some(top)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
|
|
5
|
+
function confidentMatch(candidates, minRelevanceOpt, ambiguityMarginOpt) {
|
|
6
|
+
let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : 0.97;
|
|
7
|
+
let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : 0.01;
|
|
8
|
+
let top = candidates[0];
|
|
9
|
+
if (top === undefined) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let topScore = top.relevance;
|
|
13
|
+
if (topScore === undefined) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (topScore < minRelevance) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let runnerUp = Stdlib_Option.flatMap(candidates[1], c => c.relevance);
|
|
20
|
+
if (runnerUp !== undefined && topScore - runnerUp < ambiguityMargin) {
|
|
21
|
+
return;
|
|
22
|
+
} else {
|
|
23
|
+
return top;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let defaultMinRelevance = 0.97;
|
|
28
|
+
|
|
29
|
+
let defaultAmbiguityMargin = 0.01;
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
defaultMinRelevance,
|
|
33
|
+
defaultAmbiguityMargin,
|
|
34
|
+
confidentMatch,
|
|
35
|
+
}
|
|
36
|
+
/* No side effect */
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
An amount of money: a whole number of a currency's minor units, and the currency
|
|
3
|
+
those units belong to.
|
|
4
|
+
|
|
5
|
+
## Why the currency travels with the number
|
|
6
|
+
|
|
7
|
+
A minor unit is currency-dependent — ISO 4217 gives EUR two decimal places, **JPY
|
|
8
|
+
zero** and **TND three** — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, and
|
|
9
|
+
there is no way to tell which. It cannot be rendered, compared, summed or
|
|
10
|
+
sanity-checked without knowing. The currency is part of the number's meaning, not
|
|
11
|
+
metadata beside it.
|
|
12
|
+
|
|
13
|
+
The alternative considered and rejected was a branded `amount` scalar with the
|
|
14
|
+
currency held once on the aggregate. Its appeal is real: mixing currencies
|
|
15
|
+
becomes unrepresentable rather than merely checkable. But it only works while
|
|
16
|
+
every amount an aggregate touches shares one currency, and the moment one does
|
|
17
|
+
not, the information needed to notice has already been deleted. `add` checks
|
|
18
|
+
instead — see below.
|
|
19
|
+
|
|
20
|
+
## Why whole minor units and not a decimal major amount
|
|
21
|
+
|
|
22
|
+
`0.1 +. 0.2` is not `0.3`, and money is summed. Minor units keep every amount an
|
|
23
|
+
exact integer, so addition is exact and equality means what it says.
|
|
24
|
+
|
|
25
|
+
## Why `float` for a whole number
|
|
26
|
+
|
|
27
|
+
Because ReScript's `int` is int32, and sury enforces that — an `int` amount caps
|
|
28
|
+
at 2,147,483,647 minor units, which is €21,474,836.47. A framework type that
|
|
29
|
+
cannot express a €22M total is not a money type. `float` is exact for every
|
|
30
|
+
integer below 2^53 (about €90 trillion in cents), and the wholeness that `int`
|
|
31
|
+
would have given for free is recovered by checking it in `schema`.
|
|
32
|
+
|
|
33
|
+
This is the same correction `Bytes` already made for the same reason, and the
|
|
34
|
+
wire form is identical either way: both are JSON numbers.
|
|
35
|
+
|
|
36
|
+
## How a field declares it
|
|
37
|
+
|
|
38
|
+
Unlike the branded scalars, this is not an `@s.matches` refinement — the field's
|
|
39
|
+
declared type *is* `Money.t`, and sury-ppx resolves it to this module's `schema`:
|
|
40
|
+
|
|
41
|
+
```rescript
|
|
42
|
+
@schema type state = {
|
|
43
|
+
productId: string,
|
|
44
|
+
price: Reventless.Money.t,
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
which serializes as `{"amount": 1000, "currency": "EUR"}`.
|
|
49
|
+
|
|
50
|
+
**That is a structural change to the field.** Retyping an existing `price: float`
|
|
51
|
+
rewrites the wire shape, so stored events no longer decode and projections must
|
|
52
|
+
be rebuilt. Retyping a field in a log that has to survive needs an upcaster
|
|
53
|
+
first; a log that can be discarded can take it today.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
Validate a minor-unit amount, saying why when it is not one.
|
|
58
|
+
|
|
59
|
+
The single definition of what an amount may hold; `amountSchema` is derived from
|
|
60
|
+
it rather than hand-rolling a second check, the rule `StorageRef` established.
|
|
61
|
+
*/
|
|
62
|
+
let validateAmount = (amount: float): result<float, string> =>
|
|
63
|
+
if !Float.isFinite(amount) {
|
|
64
|
+
Error(`an amount must be a finite number of minor units, got ${Float.toString(amount)}`)
|
|
65
|
+
} else if amount !== Math.trunc(amount) {
|
|
66
|
+
Error(
|
|
67
|
+
`an amount is a whole number of a currency's minor units, got ` ++
|
|
68
|
+
`${Float.toString(amount)}. There is no such thing as a fraction of the ` ++
|
|
69
|
+
`smallest unit — a major amount converts with Money.ofMajor.`,
|
|
70
|
+
)
|
|
71
|
+
} else {
|
|
72
|
+
Ok(amount)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The amount's own schema. The check sits on the field rather than on the pair
|
|
76
|
+
because wholeness is a property of the amount — and because sury 11-alpha
|
|
77
|
+
miscompiles a refinement wrapping a *record* schema (it hoists the result
|
|
78
|
+
object above the field reads, so both parse and serialize throw
|
|
79
|
+
`Cannot access 'v0' before initialization`). Refining the field is both the
|
|
80
|
+
honest placement and the one that works. */
|
|
81
|
+
let amountSchema: S.t<float> =
|
|
82
|
+
S.float->S.refine(s => amount =>
|
|
83
|
+
switch validateAmount(amount) {
|
|
84
|
+
| Ok(_) => ()
|
|
85
|
+
| Error(why) => s.fail(why)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@schema
|
|
90
|
+
type t = {
|
|
91
|
+
/** Whole minor units of `currency` — 1000 is €10.00, ¥1000 or 1.000 TND
|
|
92
|
+
depending on which. Negative amounts are allowed: a refund is money. */
|
|
93
|
+
amount: @s.matches(amountSchema) float,
|
|
94
|
+
currency: Currency.t,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The sury schema for a money field, carrying the `money` semantic.
|
|
98
|
+
|
|
99
|
+
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
100
|
+
the shape, and this adds the marker the shape cannot carry. */
|
|
101
|
+
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.money)
|
|
102
|
+
|
|
103
|
+
/** An amount already counted in minor units. */
|
|
104
|
+
let make = (~amount: float, ~currency: Currency.t): t => {amount, currency}
|
|
105
|
+
|
|
106
|
+
/** Nothing, in a currency. A zero still has a currency — "no money" and "no
|
|
107
|
+
euros" are different claims, and only the second one adds to a total. */
|
|
108
|
+
let zero = (~currency: Currency.t): t => {amount: 0.0, currency}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
Convert a major-unit decimal (`10.5`) into minor units (`1050`), using the
|
|
112
|
+
currency's own exponent.
|
|
113
|
+
|
|
114
|
+
This is the one place a decimal is allowed to become money, and it is here rather
|
|
115
|
+
than at each call site precisely so that `*. 100.0` is written once and is
|
|
116
|
+
correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`.
|
|
117
|
+
|
|
118
|
+
Rounds half away from zero (`10.005` EUR → `1001`), which is what a reader
|
|
119
|
+
expects of a price. That is a *boundary conversion* and not an arithmetic
|
|
120
|
+
policy: the half-even question that FX and tax rounding turn on is a separate
|
|
121
|
+
decision, and nothing here forecloses it.
|
|
122
|
+
*/
|
|
123
|
+
let ofMajor = (~amount: float, ~currency: Currency.t): t => {
|
|
124
|
+
let scale = Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
|
|
125
|
+
{amount: Math.round(amount *. scale), currency}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The amount as a major-unit decimal — for charts, averages and anything that
|
|
129
|
+
has to be a number rather than money. Lossy by nature: the result is a float
|
|
130
|
+
again, so it is an output, not something to compute a balance in. */
|
|
131
|
+
let toMajor = (m: t): float =>
|
|
132
|
+
m.amount /. Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(m.currency)))
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
The amount as text: the decimal point placed by the currency's exponent, digits
|
|
136
|
+
grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"`,
|
|
137
|
+
`"1.000 TND"`.
|
|
138
|
+
|
|
139
|
+
Deliberately locale-independent, matching the rest of the framework's
|
|
140
|
+
formatters: the same value reads the same in every log line and every test. A
|
|
141
|
+
locale-aware, symbol-bearing rendering is the presentation layer's job, and it
|
|
142
|
+
has the currency code to do it with.
|
|
143
|
+
*/
|
|
144
|
+
let format = (m: t): string => {
|
|
145
|
+
let exponent = Currency.exponent(m.currency)
|
|
146
|
+
let negative = m.amount < 0.0
|
|
147
|
+
let digits = Float.toString(negative ? -.m.amount : m.amount)
|
|
148
|
+
// Pad so there is always at least one digit left of the point: 5 minor units
|
|
149
|
+
// of EUR is "0.05", not ".05".
|
|
150
|
+
let padded = digits->String.padStart(exponent + 1, "0")
|
|
151
|
+
let split = String.length(padded) - exponent
|
|
152
|
+
let whole = padded->String.slice(~start=0, ~end=split)
|
|
153
|
+
let fraction = padded->String.slice(~start=split, ~end=String.length(padded))
|
|
154
|
+
let rec group = (s: string): string => {
|
|
155
|
+
let length = String.length(s)
|
|
156
|
+
length <= 3
|
|
157
|
+
? s
|
|
158
|
+
: group(s->String.slice(~start=0, ~end=length - 3)) ++
|
|
159
|
+
"," ++
|
|
160
|
+
s->String.slice(~start=length - 3, ~end=length)
|
|
161
|
+
}
|
|
162
|
+
(negative ? "-" : "") ++
|
|
163
|
+
group(whole) ++
|
|
164
|
+
(exponent == 0 ? "" : "." ++ fraction) ++
|
|
165
|
+
" " ++
|
|
166
|
+
Currency.toString(m.currency)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
Add two amounts, refusing to add across currencies.
|
|
171
|
+
|
|
172
|
+
This is §15.1's second rider made executable. The genuine appeal of the
|
|
173
|
+
branded-scalar shape was that mixing currencies could not be written at all; the
|
|
174
|
+
answer is to *check* it rather than to delete the information that makes checking
|
|
175
|
+
possible. An aggregate that must hold one currency rejects a line item in
|
|
176
|
+
another — a decider's concern, and one it can now actually express.
|
|
177
|
+
*/
|
|
178
|
+
let add = (a: t, b: t): result<t, string> =>
|
|
179
|
+
a.currency == b.currency
|
|
180
|
+
? Ok({amount: a.amount +. b.amount, currency: a.currency})
|
|
181
|
+
: Error(
|
|
182
|
+
`cannot add ${format(b)} to ${format(a)}: they are different currencies. ` ++
|
|
183
|
+
`Converting between them needs a rate, which is not something an amount carries.`,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
/** Add a run of amounts, refusing at the first currency that does not match the
|
|
187
|
+
first amount's. `None` for an empty run — the sum of no amounts has no
|
|
188
|
+
currency to be in. */
|
|
189
|
+
let sum = (amounts: array<t>): option<result<t, string>> =>
|
|
190
|
+
switch amounts {
|
|
191
|
+
| [] => None
|
|
192
|
+
| _ => Some(amounts->Array.reduce(Ok(zero(~currency=(amounts->Array.getUnsafe(0)).currency)), (
|
|
193
|
+
acc,
|
|
194
|
+
m,
|
|
195
|
+
) => acc->Result.flatMap(total => add(total, m))))
|
|
196
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Currency$Reventless from "./Currency.res.mjs";
|
|
7
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
8
|
+
|
|
9
|
+
function validateAmount(amount) {
|
|
10
|
+
if (isFinite(amount)) {
|
|
11
|
+
if (amount !== Math.trunc(amount)) {
|
|
12
|
+
return {
|
|
13
|
+
TAG: "Error",
|
|
14
|
+
_0: `an amount is a whole number of a currency's minor units, got ` + (amount.toString() + `. There is no such thing as a fraction of the `) + `smallest unit — a major amount converts with Money.ofMajor.`
|
|
15
|
+
};
|
|
16
|
+
} else {
|
|
17
|
+
return {
|
|
18
|
+
TAG: "Ok",
|
|
19
|
+
_0: amount
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
return {
|
|
24
|
+
TAG: "Error",
|
|
25
|
+
_0: `an amount must be a finite number of minor units, got ` + amount.toString()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let amountSchema = S.refine(S.float, s => (amount => {
|
|
31
|
+
let why = validateAmount(amount);
|
|
32
|
+
if (why.TAG === "Ok") {
|
|
33
|
+
return;
|
|
34
|
+
} else {
|
|
35
|
+
return s.fail(why._0, undefined);
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
let schema = S.schema(s => ({
|
|
40
|
+
amount: s.m(amountSchema),
|
|
41
|
+
currency: s.m(Currency$Reventless.schema)
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.money, undefined);
|
|
45
|
+
|
|
46
|
+
function make(amount, currency) {
|
|
47
|
+
return {
|
|
48
|
+
amount: amount,
|
|
49
|
+
currency: currency
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function zero(currency) {
|
|
54
|
+
return {
|
|
55
|
+
amount: 0.0,
|
|
56
|
+
currency: currency
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ofMajor(amount, currency) {
|
|
61
|
+
let scale = Math.pow(10.0, Currency$Reventless.exponent(currency));
|
|
62
|
+
return {
|
|
63
|
+
amount: Math.round(amount * scale),
|
|
64
|
+
currency: currency
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toMajor(m) {
|
|
69
|
+
return m.amount / Math.pow(10.0, Currency$Reventless.exponent(m.currency));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function format(m) {
|
|
73
|
+
let exponent = Currency$Reventless.exponent(m.currency);
|
|
74
|
+
let negative = m.amount < 0.0;
|
|
75
|
+
let digits = (
|
|
76
|
+
negative ? - m.amount : m.amount
|
|
77
|
+
).toString();
|
|
78
|
+
let padded = digits.padStart(exponent + 1 | 0, "0");
|
|
79
|
+
let split = padded.length - exponent | 0;
|
|
80
|
+
let whole = padded.slice(0, split);
|
|
81
|
+
let fraction = padded.slice(split, padded.length);
|
|
82
|
+
let group = s => {
|
|
83
|
+
let length = s.length;
|
|
84
|
+
if (length <= 3) {
|
|
85
|
+
return s;
|
|
86
|
+
} else {
|
|
87
|
+
return group(s.slice(0, length - 3 | 0)) + "," + s.slice(length - 3 | 0, length);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
negative ? "-" : ""
|
|
92
|
+
) + group(whole) + (
|
|
93
|
+
exponent === 0 ? "" : "." + fraction
|
|
94
|
+
) + " " + Currency$Reventless.toString(m.currency);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function add(a, b) {
|
|
98
|
+
if (a.currency === b.currency) {
|
|
99
|
+
return {
|
|
100
|
+
TAG: "Ok",
|
|
101
|
+
_0: {
|
|
102
|
+
amount: a.amount + b.amount,
|
|
103
|
+
currency: a.currency
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
} else {
|
|
107
|
+
return {
|
|
108
|
+
TAG: "Error",
|
|
109
|
+
_0: `cannot add ` + format(b) + ` to ` + format(a) + `: they are different currencies. Converting between them needs a rate, which is not something an amount carries.`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sum(amounts) {
|
|
115
|
+
if (amounts.length !== 0) {
|
|
116
|
+
return Stdlib_Array.reduce(amounts, {
|
|
117
|
+
TAG: "Ok",
|
|
118
|
+
_0: {
|
|
119
|
+
amount: 0.0,
|
|
120
|
+
currency: amounts[0].currency
|
|
121
|
+
}
|
|
122
|
+
}, (acc, m) => Stdlib_Result.flatMap(acc, total => add(total, m)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export {
|
|
127
|
+
validateAmount,
|
|
128
|
+
amountSchema,
|
|
129
|
+
schema$1 as schema,
|
|
130
|
+
make,
|
|
131
|
+
zero,
|
|
132
|
+
ofMajor,
|
|
133
|
+
toMajor,
|
|
134
|
+
format,
|
|
135
|
+
add,
|
|
136
|
+
sum,
|
|
137
|
+
}
|
|
138
|
+
/* amountSchema Not a pure module */
|