FTS from Python, Go and shell: working clients, not declarations
FTS is not tied to Node.js. The compiler is written in TypeScript, but the boundary between
it and the rest of the code is not a library, but a process and JSON: any language that
can run a command and parse stdout can execute the FTS utility.
Further — three working clients: Python, Go, and shell, the same contract,
the same result. All three are in examples/fts/clients/ and indeed
run, not just read.
Minimal example
The model around which the entire module is built is calculating a discount on a purchase. It
is also used by the HTTP service in examples/fts/discount-api and the clients below:
a single source of truth for the rule, three ways to invoke it.
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
What the compiler does
fts compile turns the text above into one JSON object: category, structures with fields and types, utilities with rules and examples. fts test runs the examples and compares expected with actual. fts run --utility ... --input purchase.json executes a specific utility on specific data and prints the result. None of this requires TypeScript on the caller’s side — only the ability to read JSON.
Boundary through JSON
Canonical document — public offer, not internal detail. In upstream
there is its JSON Schema (schema/document.schema.json): it requires fields
category, structures, functors, proposition, ts_compat and allows
utilities. This is how the model above looks after fts compile (shortened):
{
"category": "Продажи",
"structures": [
{ "name": "Покупка", "fields": [
{ "name": "сумма", "type": "Деньги" },
{ "name": "постоянный клиент", "type": "Признак" }
] }
],
"functors": [],
"proposition": null,
"ts_compat": {},
"utilities": [
{ "name": "Рассчитать скидку", "input": "Покупка", "output": "Деньги",
"initial": 0, "rules": [ /* … */ ], "properties": [ /* … */ ],
"examples": [ /* … */ ] }
]
}
Any language can validate this document with a schema without having an FTS parser —
a regular JSON Schema validator is sufficient (jsonschema in Python, ajv in
Node, any equivalent in Go). Next to it lies schema/proof-certificate.schema.json
for proof certificates — the same idea: the certificate structure is public
and is verified independently of who issued it.
The CLI contract is simple and the same for all teams:
- success — JSON result in stdout, exit code
0; - error —
{"error": "...", "diagnostics": [...]}in stderr, exit code not equal to0(1— domain or validation error,2— arguments error).
The real fts is built from the @digitable-lol/fts package; the source code is open
(github.com/digitable-lol/flang),
but it is not yet published as an npm package. Therefore, the examples below use
examples/fts/clients/fts-cli.mjs — a thin bridge over the same vendor
build that powers the sandbox, replicating the compile / check / test / run contract. Replacing the bridge with the real fts does not change any client —
only the team name in one line.
Client in Python
examples/fts/clients/python/calculate_discount.py — only subprocess and
json from the standard library:
def calculate_discount(amount, loyal, cli="bridge", model=DEFAULT_MODEL):
purchase = {"сумма": amount, "постоянный клиент": loyal}
input_path = write_temp_json(purchase)
command = build_cli_command(cli, model) + ["--utility", UTILITY, "--input", str(input_path)]
completed = subprocess.run(command, capture_output=True, text=True)
if completed.returncode != 0:
raise RuntimeError(describe_failure(completed))
return json.loads(completed.stdout)["result"]
The utility data is passed by file: the CLI contract requires --input путь.json,
not stdin, so a temporary file is not an extra detail, but a boundary requirement.
The error is not swallowed: describe_failure parses stderr as JSON and extracts
error and diagnostic codes, and if stderr is not JSON at all (CLI not found) —
displays what is there instead of a silent failure.
Execution and actual output:
$ python3 calculate_discount.py
сумма=20000 постоянный_клиент=False -> скидка=2000
$ python3 calculate_discount.py --сумма 20000 --постоянный-клиент
сумма=20000.0 постоянный_клиент=True -> скидка=3000
Client in Go
examples/fts/clients/go/main.go uses only os/exec and
encoding/json:
func calculateDiscount(sum float64, loyal bool, cli, model string) (float64, error) {
purchase := map[string]any{"сумма": sum, "постоянный клиент": loyal}
inputFile, err := writeTempInput(purchase)
// ...
cmd := exec.Command(command[0], command[1:]...)
// stdout и stderr пишутся в отдельные буферы —
// diagnostics не должны потеряться, даже если процесс упал
runErr := cmd.Run()
if runErr != nil {
return 0, fmt.Errorf("%s", describeFailure(stderrBuf, stdoutBuf, runErr))
}
var result runResult
json.Unmarshal(stdout, &result)
return result.Result, nil
}
Structures runResult and errorResult in the code type both possible outcomes
of the JSON contract — success and error — so that encoding/json parses them without
interface{} and type casts. Built and checked locally (go1.26.3):
$ go run main.go
сумма=20000 постоянный_клиент=false -> скидка=2000
$ go run main.go -sum 20000 -loyal
сумма=20000 постоянный_клиент=true -> скидка=3000
Client in shell
examples/fts/clients/shell/discount.sh — bash plus jq. To write a proper
JSON parser in pure bash without jq won’t work: unicode and quote
escaping in сумма/«постоянный клиент» — exactly the case where a homemade grep/sed
silently breaks on the first non-standard value. jq is present in the system almost
always, and its absence the client checks and explicitly reports about.
jq -n --argjson sum "$SUM" --argjson loyal "$LOYAL" \
'{"сумма": $sum, "постоянный клиент": $loyal}' > "$INPUT_FILE"
"${CMD[@]}" >"$STDOUT_FILE" 2>"$STDERR_FILE"
STATUS=$?
if [[ $STATUS -ne 0 ]]; then
ERROR_MESSAGE="$(jq -r '.error' "$STDERR_FILE" 2>/dev/null || true)"
echo "ошибка: $ERROR_MESSAGE" >&2
exit "$STATUS"
fi
set -e is disabled during the CLI call intentionally: without this, the return code would be lost before the script has a chance to read stderr and show diagnostics to the user. Actual output:
$ ./discount.sh
сумма=20000 постоянный_клиент=false -> скидка=2000
$ ./discount.sh 20000 true
сумма=20000 постоянный_клиент=true -> скидка=3000
Through HTTP instead of CLI
If launching the process on each call is expensive (high frequency, short SLA),
the alternative is not CLI, but a long-running HTTP service. examples/fts/discount-api
already does this: the model is compiled and tested once at startup, then
Node.js only routes requests, while FTS makes the decision.
node examples/fts/discount-api/server.mjs
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}
Login error is returned with the same body as in CLI — error and
diagnostics — only with code 400 instead of a non-zero exit code:
{
"error": "поле «сумма» не соответствует типу «Деньги»",
"diagnostics": [{ "code": "FTS_UTILITY_INPUT_TYPE", "message": "…", "severity": "error" }]
}
Any client from the sections above can be rewritten on requests/net/http/curl
instead of subprocess/os/exec/CLI-bridge — the response parsing does not change at all,
because the body is the same as what CLI prints. The choice between CLI and HTTP is
the choice between simplicity (one process per call, nothing to keep alive) and
throughput (the model is compiled once, then only the network).
Third way: not to call the compiler, but to print the model into your language
Both approaches above have a common feature: a Node process lives next to your code and counts. Sometimes this is not possible — there is no Node on the target machine, the service cannot be started, or it is not possible to pay for running the process on invocation. Then the third approach works, and it appeared recently.
FTS has grown into a full-fledged language — flang, — for which the .fts model is
a valid program (module
«FTS toolchain»). The language has
code generation, and it compiles to eight targets:
$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, csharp, elixir, go, java, js, python, rust", …}
The input is the same file .fts that you have checked throughout the course using fts check.
Let’s take Java — that case when “lifting Node nearby” is usually not even discussed:
$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target java --out out-java
{"target":"java","module":"Продажи","files":[
{"path":"Value.java","bytes":21992}, {"path":"Field.java","bytes":1619},
{"path":"FlangError.java","bytes":4920}, {"path":"Ctx.java","bytes":5676},
{"path":"Flang.java","bytes":35537}, {"path":"Prodazhi.java","bytes":5927},
{"path":"FlangCli.java","bytes":21963}, {"path":"Makefile","bytes":1077}]}
$ make build
javac -encoding UTF-8 -Xlint:all -Werror -d . *.java
$ printf '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"n":"20000"}],["постоянный клиент",true]]}]}\n' \
| java -cp . FlangCli Prodazhi
{"ok":true,"value":{"n":"3000"}}
Three thousand — exactly what fts run gives on the same data and what is recorded in
the “Big purchase by a regular customer” example in the model itself. Note what is not present in this chain: Node on the calling side.
Runner FlangCli in the example above — only so that the result is
visible in the console; it is not needed in your program itself. In Prodazhi.java
are ordinary static methods, and the utility model is one of them:
Ctx ctx = Prodazhi.newContext();
Value покупка = Prodazhi.rec_pokupka(Value.number(20000), Value.flag(true));
Value скидка = Prodazhi.fn_rasschitat_skidku(ctx, покупка); // 3000
Role is part of every name (fn_ for the function, rec_ for the record constructor) not for beauty: a Java class is one namespace, while in the FTS model the same name can easily be shared by both an object and a utility. If they collided after transliteration — the class would simply fail to build, and the backend treats such a collision as a printing error, not a reason to silently rename someone.
Matching here is not “usually converging”, but a requirement that is checked. For
flang code generation, one common rule applies to all eight backends: the printed code
must give the same value and the same error — code and text, as the interpreter gives. From this rule grow decisions that would otherwise look like nitpicks: numbers are IEEE-754 everywhere (in the C# backend decimal is rejected precisely
for this reason — in it 0.1 плюс 0.2 would give exactly 0.3), percentages are printed as
(процент / 100) * значение in this order, diagnostic texts are copied
literally down to the curly quotes.
What This Means for the Choice of Boundary
There are now three approaches, and one question determines the choice between them — what you are willing to keep alongside your code.
| Method | What Lives Nearby | When to Take |
|---|---|---|
| CLI-process | Node and model | rare invocations, scripts, CI |
| HTTP-service | Node and model, but once for all | high frequency, short SLA |
| printing to target language | nothing — only your code | Node is unavailable or a process per invocation is unacceptable |
The third approach has a cost, and it should be mentioned. Printed code is a
snapshot of the model at the time of printing: changed .fts — must print again,
otherwise you will diverge. Exactly the same problem as with generated TypeScript, and
it is solved exactly the same way — a --check step in CI, which compares
the printed code with the model (module
«Generation and CI»). The first two approaches
do not have this cost: there, the model is read on every run.
And the same was done with the compiler itself
This is no longer about integration, but it’s worth mentioning because it shows how general the approach is.
The FTS core — lexer, parser, utility calculator, and canonical JSON printer — rewritten from TypeScript to flang: four files, 300 functions, all in a total class. And since this is a program in flang, it can be printed in C:
$ node flang/bin/flang.mjs emit flang/core/parser.flang --target c --out core-c
{"target":"c","module":"Парсер FTS","files":[…, {"path":"parser_fts.c","bytes":914838}, …]}
$ cd core-c && make
…
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2 -o flang_cli flang_cli.o flang_runtime.o parser_fts.o -lm
The resulting binary is the FTS compiler without Node. We fed it the source
code of the model from this module and compared the result with what the
TypeScript core gives: the canonical JSON matched byte-for-byte, 1195 bytes
in 1195. The same document that you’ve been getting all semester with the fts compile
command is now also available as a native binary.
The criterion by which this core is considered correct should be taken independently of FTS: not «pass own tests», but «on all repository models the old core and the new one produce byte-for-byte identical JSON, including codes and texts of diagnostics». The private test base checks what the author thought of; verification against a working predecessor checks everything the predecessor can do — including behavior that no one remembers anymore.
Practice in the sandbox
Change the amount or the loyal customer flag and see which JSON goes to the CLI and which returns — the same utility that the clients above execute.
Common Fallacies
Below are real diagnostic codes reproduced on the model from this module.
FTS_UTILITY_INPUT_TYPE — field is not of the type:
{"сумма": "много", "постоянный клиент": true}
{"error": "поле «сумма» не соответствует типу «Деньги»", "diagnostics": [{"code": "FTS_UTILITY_INPUT_TYPE", "severity": "error"}]}
FTS_UTILITY_INPUT — a field is missing in the input data (in the example above
«постоянный клиент» is missing); the same code is used if the utility itself
is not found in the input structure.
FTS_UTILITY_INPUT_FIELD — an extra field is present in the input data that does not exist
in the Покупка structure. FTS does not silently ignore extra keys: this is usually
an indication that the client and the model have diverged in their contract.
FTS_UNKNOWN_UTILITY — typo in the utility name (--utility "Расчитать скидку" instead of «Рассчитать скидку»).
FTS_NO_UTILITY_EXAMPLES — fts test invoked on utility without a single
пример «…»: nothing to test, and the compiler states this explicitly, rather
than silently returning an empty report.
General rule for the client in any language: do not parse error text manually.
Branching should be done by diagnostics[].code — it is stable between versions, while
the message wording is not.
Checklist
- The client calls the CLI and parses stdout as JSON only at exit code
0; at nonzero — readsstderras{error, diagnostics}. - Utility input data is passed via a file (
--input path.json), not as a string in arguments: this makes the path visible in diagnostics on failure. - Branching on error — through
diagnostics[].code, not through the texterror. - For infrequent calls — a CLI process; for high frequency — an HTTP service with a model compiled once at startup.
- The canonical model, not just the utility result, can be validated with an external JSON Schema — this works even without CLI, on an already saved document.