Skip to content

Expose native column encoder context - #6249

Merged
JorisGoosen merged 9 commits into
jasp-stats:developmentfrom
FBartos:bridge/native-decoder-api
Jun 5, 2026
Merged

Expose native column encoder context#6249
JorisGoosen merged 9 commits into
jasp-stats:developmentfrom
FBartos:bridge/native-decoder-api

Conversation

@FBartos

@FBartos FBartos commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR exposes the Desktop-owned encoder context needed by the jaspTools -> jaspSyntax bridge:

  • replaces the exported decoder snapshot/mapping API with a serializable ColumnEncoder context
  • keeps the context/schema/scoped restore helper in Common/columnencodercontext.*, next to ColumnEncoder
  • captures dataset column names/types and extra QML option encodings from the native source state
  • decodes text by temporarily restoring native ColumnEncoder state and then calling the normal decoder path
  • removes the native decodeAllWithMapping() compatibility path so R cannot silently decode from a stale map
  • keeps native bridge failures structured at the C ABI boundary

Why

Encoding/decoding should remain an internal Desktop/SyntaxInterface concern. The R packages now pass around an opaque encoder context, and Desktop remains the only implementation of token replacement. That prevents incorrect replay when the live dataset has changed, and it avoids duplicating encoding rules in jaspSyntax, jaspBase, or jaspTools.

Related PRs

Validation

  • rebuilt SyntaxInterface with ninja -C build -j1 SyntaxInterface through the local MSVC toolchain
  • verified the jaspSyntax/SyntaxInterface C ABI exports with tools/check-syntaxinterface-symbols.sh
  • ran focused jaspSyntax bridge tests against the rebuilt Desktop target: test-desktop-jasp-contract.R, test-dataset-helpers.R
  • ran focused jaspBase tests: test-result-object-decoding.R, test-runWrappedAnalysis.R
  • ran focused jaspTools bridge lifecycle tests: test-jaspSyntax-lifecycle.R
  • ran R parse checks for jaspSyntax, jaspBase, and jaspTools
  • ran git diff --check

Note: this PR should be reviewed together with the linked package PRs because the contract is intentionally split by ownership: Desktop owns token replacement, jaspSyntax exposes the bridge, jaspBase decodes JASP-owned result surfaces, and jaspTools carries the captured context through replay.

Expose SyntaxInterface decoder snapshots and text decoding for jaspSyntax, add status-returning QML option parsing, and make quiet bridge logging actually discard output. Ensure the native ColumnEncoder is initialized before option encoding/decoding and catch decoder bridge failures at the C ABI boundary.
@vandenman

Copy link
Copy Markdown
Contributor

@JorisGoosen do you have time to review this? You're more familiar with the decoding stuff than I am. The basic idea is that we want to export its contents as json, so that we can reinstantiate the decoder in R in jaspSyntax. This is necessary when you save results to disk, restart R, and then load the results again. However, the round trip should be perfect and easy to maintain in the future (so you should understand what's going on here).

Comment thread Common/log.cpp Outdated
Comment on lines +18 to +30
namespace
{
class NullLogBuffer : public std::streambuf
{
protected:
int overflow(int c) override { return traits_type::not_eof(c); }
};

NullLogBuffer nullLogBuffer;
std::ostream nullLogStream(&nullLogBuffer);
}

std::ostream* Log::_nullStream = &nullLogStream;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is a good idea but I will need to know why this was done?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed so logType::null actually discards output in the static jaspSyntax bridge. Before this change _nullStream pointed at std::cout, so syntaxBridgeSetVerbose(false) could still let native bridge logging reach stdout before Log::init() installed an application-owned null stream. The local streambuf is a true sink; verbose mode still switches the bridge back to stdout. I added a short code comment for that rationale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could just call the following in the syntaxbridge constructor:

Log::logFileNameBase = (AppDirs::logDir() + "JASP "  + getSortableTimestamp()).toStdString();
Log::init(&nullstream);
Log::setLogFileName(Log::logFileNameBase + " Desktop.log");
Log::setLoggingToFile(_preferences->logToFile());

Like MainWindow::initLog ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is exactly the issue this was trying to fix: when the static bridge is used from R/jaspSyntax, MainWindow::initLog() is never called, so logType::null still pointed at std::cout and JASP/native messages leaked into R sessions even with verbose disabled.

I agree this is bridge initialization concern rather than part of the decoder API. Before I patch it: do you want the SyntaxInterface bridge to only install a local null stream via Log::init(&nullStream) and then toggle Log::setDefaultDestination() / Log::setWhere() from syntaxBridgeSetVerbose(), or do you want the bridge to do the fuller MainWindow::initLog()-style log-file setup as well?

My preference would be the small bridge-local null-stream initialization, without Desktop preferences/log-file setup, because the R/headless bridge should not depend on PreferencesModel or Desktop-owned logging policy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use a nullstream like the one in MainWindow::initLog. And then Log::init(&nullstream). Much simpler than the code in this PR. And ofc in the syntaxbridge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c21e37f. I reverted the Common/log.cpp change entirely, so the global logging defaults outside SyntaxInterface are unchanged.

SyntaxInterface now owns a local null stream and calls Log::init(&nullstream) from configureBridgeLogging() before bridge status/error paths can log. syntaxBridgeSetVerbose() only toggles between cout and null; no Desktop preference/file logging is needed for this standalone bridge path. I also smoke-tested the jaspSyntax status-only error path with verbose = FALSE; it returns the structured error with zero captured native output.

@JorisGoosen JorisGoosen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this PR has some good ideas, but its mostly adding a lot of hassle on top of columnencoder.

Instead of adding all of these functions it would be a lot easier to simply store the output of DataSet::getColumnTypesMap() somewhere in your R datastructure (so you can reload it on demand). Then when you need to decode stuff you just use ColumnEncoder as usual after setting those columnTypes in setCurrentColumnNames.

Then there is almost no extra code to maintain and everything happens in the same way.

Or is there something Im missing that makes this obligatory?

@FBartos FBartos changed the title Expose native column decoder bridge APIs Expose native column encoder context Jun 3, 2026
@JorisGoosen

Copy link
Copy Markdown
Contributor

jaspBase shouldnt be updated.

The extra columnencoder related classes should be either in the file WITH columnencoder or in a separate class next to columnencoder. but not with syntaxbridge.

@FBartos

FBartos commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

@JorisGoosen addressed in ed7c12e.

  • Removed the Engine/jaspBase gitlink from this Desktop PR. The jaspBase work stays isolated in Support source-module wrapped analysis replay jaspBase#204.
  • Moved the encoder context/schema/scoped restore code out of SyntaxInterface into Common/columnencodercontext.*, next to ColumnEncoder.
  • SyntaxBridge now only handles the exported C ABI: it captures DataSet::getColumnTypesMap() plus the extra-option encoder state, and decoding restores those maps into ColumnEncoder before calling the normal ColumnEncoder::decodeAll() path.

So the package side stores/passes an opaque context, and Desktop remains the only place implementing token replacement. I also re-ran the native build plus the focused jaspSyntax, jaspBase, and jaspTools bridge tests listed in the PR body.

@FBartos
FBartos requested a review from JorisGoosen June 3, 2026 14:02
Comment thread SyntaxInterface/syntaxbridge.cpp Outdated
return values;
}

static Json::Value decodeColumnTextJson(const Json::Value & values, const ColumnDecoderSnapshot & snapshot)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole function looks like it should just call setCurrentColumnNames first with the stuff from the snapshot. And than ColumnEncoder::decodeJson. Not really sure why we need to have this whole function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this should be simplified. I can change the native path to parse the incoming JSON, temporarily install the captured context into ColumnEncoder, call the standard ColumnEncoder::decodeJson(), and restore the previous state before returning.

One detail to confirm: should this bridge call decode arbitrary JSON with replaceNames = true, or keep the current R contract as a character-vector JSON payload and still use decodeJson() internally? The former is more directly the standard ColumnEncoder API; the latter keeps the R API smaller and avoids exposing more surface than jaspSyntax currently needs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is the standard API for ColumnEncoder it is already accessible from R via jaspBase right? So that should make "The R API smaller" than the latter? Or am I missing something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are not missing much; my phrasing about "R API smaller" was imprecise.

jaspBase::decodeColNames() / decodeName() are module-facing convenience helpers. They eventually resolve the decoder functions available in the current JASP runtime, but they are not a standalone way for jaspSyntax to install a saved Desktop ColumnEncoder context after the live dataset has changed. The dependency direction should also stay jaspBase -> jaspSyntax/native bridge for replay, not jaspSyntax -> jaspBase.

So I think the native bridge still needs one minimal context-aware decode entry point. But I agree it should be generic and standard-ColumnEncoder-shaped: context JSON + payload JSON in, install context, call ColumnEncoder::decodeJson(), restore, return decoded JSON. Then the exported R helper can remain a thin convenience wrapper for character vectors if that is all current callers need.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok well im curious to see it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c21e37f. The bridge-specific manual JSON decoding loop is gone.

decodeColumnJson() now parses the payload, installs the snapshot context with ScopedColumnEncoderContext, calls the standard ColumnEncoder::decodeJson(payload, replaceNames), and returns the decoded payload. The payload is no longer restricted to a string array, so the normal ColumnEncoder traversal remains the single implementation for decoded JSON objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The standard decoder implementation is still the one we use, but replay needs to decode against the context captured from the source file/analysis, not whatever dataset context happens to be live when R materializes the result.

That is why I kept this as a single SyntaxInterface bridge operation instead of routing state management through jaspBase: the only cross-ABI API is decode(payload, context), and native code handles install/decode/restore internally. jaspSyntax remains a thin caller and jaspBase does not need to know about encoder mappings.

Comment thread SyntaxInterface/syntaxbridge.cpp Outdated
return result.c_str();
}

const char* STDCALL syntaxBridgeDecodeColumnText(const char* valuesJson, const char* decoderSnapshotJson)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isnt the decoder snapshot json not simply set from R? And the standard ColumnEncoder functions used? WHy all these extra functions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand the intended simplification: R should not carry mappings or use bridge-specific replacement logic; it should pass the captured native context back, native code should install that context into ColumnEncoder, and then call standard ColumnEncoder decode functions.

The point I want to clarify before patching is whether you prefer a persistent R-facing set-current-ColumnEncoder-context API, or a scoped decode call that installs the snapshot, calls ColumnEncoder::decodeJson(), and restores the previous native state before returning.

I prefer the scoped form because result replay often happens after the live dataset has changed, and persistent mutation from R is easier to misuse. But it would still remove the custom decode machinery and use the normal ColumnEncoder path. Is that aligned with what you intended?

Related detail: should the same context continue to carry the extra QML-option encoder state (JaspExtraOptions_...) and restore that alongside the dataset column encoder?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes indeed, R should not carry mappings or bridge specific logic.

A scoped decode would require a bunch of extra interfacing functions right?
Why not simply make sure to set the right context from R? or is that where the scope would be?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No extra public bridge functions should be needed. My intended scoped version is a single native decode call: R passes the captured context JSON and payload JSON, SyntaxInterface installs that context into ColumnEncoder for that call, calls ColumnEncoder::decodeJson(), and restores the previous context before returning.

So the scope is inside the native decode call, not an R-side sequence of set/decode/reset calls. That keeps the R API from leaving global native decoder state changed after replay, but still uses the standard ColumnEncoder path. I will implement it that way unless you prefer the persistent set-current-context API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SyntaxInterface installs that context into ColumnEncoder for that call, calls ColumnEncoder::decodeJson(), and restores the previous context before returning.

How?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mechanically:

  1. Parse the context JSON into the same ColumnEncoder::colTypeMap that came from DataSet::getColumnTypesMap() plus the extra-options encoder map.
  2. Save the currently active maps:
    • ColumnEncoder::columnEncoder()->currentNames()
    • gl_extraEncodings->currentNames()
  3. Install the captured maps:
    • ColumnEncoder::setCurrentColumnNames(contextColumns)
    • gl_extraEncodings->setCurrentNames(contextExtra)
  4. Parse the payload JSON and call the standard native decoder:
    • ColumnEncoder::decodeJson(payload, replaceNames)
  5. Restore the previously saved maps in an RAII guard/destructor before returning.

So the only custom code is context JSON <-> colTypeMap plus the scoped restore guard. The actual replacement would be ColumnEncoder::decodeJson(), not a separate bridge-specific decoding loop.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok sounds good.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in c21e37f with the scope inside the single native bridge call.

R/jaspSyntax still passes only the encoded payload plus the opaque encoder context. SyntaxInterface parses that context, ScopedColumnEncoderContext saves the current ColumnEncoder state, installs the snapshot names/types on ColumnEncoder and the extra encoder, calls the standard ColumnEncoder::decodeJson(), then restores the previous native state before returning. So R does not carry mappings and there are no separate set/unset calls exposed across the ABI.

@FBartos

FBartos commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

@JorisGoosen I pushed c21e37f with the changes discussed in the threads.

Summary:

  • reverted the broad Common/log.cpp null-stream change; SyntaxInterface now owns the bridge-local null stream and initializes logging there
  • removed the bridge-specific manual decoder loop; the replay path now installs the captured encoder context, calls ColumnEncoder::decodeJson(), and restores the previous native state before returning
  • kept R/jaspSyntax as a thin decode(payload, context) caller, with no exposed set/unset mapping API and no jaspBase mapping responsibility

I rechecked the three review threads after the push; they are now outdated and have implementation replies. Ready for another look.

Comment thread Common/columnencoder.h Outdated

static colVec columnNames();
static colVec columnNamesEncoded();
static const char* extraOptionsPrefix() { return "JaspExtraOptions_"; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now a double definition. Very ugly. Should be moved to DataBridge instead. That way it covers both engine and syntaxbridge

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3c0575c. I moved this out of ColumnEncoder and made DataBridge the single owner of the extra-options encoder.

Concretely:

  • removed ColumnEncoder::extraOptionsPrefix()
  • added a bridge-owned extra encoder in DataBridge
  • removed Engine::_extraEncodings
  • removed the separate SyntaxInterface gl_extraEncodings
  • changed rbridge_init() to derive the extra encoder from the active DataBridge, and rbridge_setDataBridge() now refreshes that borrowed pointer whenever SyntaxInterface recreates its DataBridge

So Engine and SyntaxInterface now share the same ownership model through DataBridge, and SyntaxBridge only borrows the encoder for context capture/decode.

@FBartos

FBartos commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

@JorisGoosen I pushed 3c0575c to address the remaining DataBridge ownership comment.

The extra-options encoder is now owned by DataBridge; Engine and SyntaxInterface no longer allocate their own copies, ColumnEncoder::extraOptionsPrefix() is gone, and rbridge_init()/rbridge_setDataBridge() borrow the encoder from the active DataBridge.

Validation after the push:

  • rebuilt SyntaxInterface
  • verified SyntaxInterface exported declarations against jaspSyntax
  • ran focused jaspSyntax tests: test-desktop-jasp-contract.R, test-dataset-helpers.R
  • ran focused jaspTools lifecycle tests: test-jaspSyntax-lifecycle.R
  • ran focused jaspBase tests: test-result-object-decoding.R, test-runWrappedAnalysis.R
  • ran git diff --check

The previous review threads are now outdated with implementation replies. Ready for another look.

Comment thread SyntaxInterface/syntaxbridge.cpp Outdated
Comment on lines +93 to +100
class SyntaxBridgeNullBuffer : public std::streambuf
{
protected:
int overflow(int c) override { return traits_type::not_eof(c); }
};

SyntaxBridgeNullBuffer gl_nullLogBuffer;
std::ostream gl_nullLogStream(&gl_nullLogBuffer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use the boost null stream we had before?

Or, if this is somehow better, why dont we replace the boost nullstream with something like this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 647fde9. I switched SyntaxInterface to the same boost::iostreams::null_sink pattern used by Engine/MainWindow and removed the local std-only null stream implementation.

One build detail: SyntaxInterface now enables /EHsc on MSVC. Without that, Boost leaves boost::throw_exception unresolved for the null stream path in this target. After that change, SyntaxInterface rebuilds cleanly.

Validation:

  • rebuilt SyntaxInterface
  • verified SyntaxInterface exports against jaspSyntax
  • ran focused jaspSyntax bridge contract test
  • smoke-tested verbose = FALSE status-error path: zero captured native output


bool init(bool dbInMemory)
{
configureBridgeLogging(gl_verbose);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see configureBridgeLogging here. Which is the logical place! Good. But why is it at all those other places?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Those extra calls were defensive leftovers from the earlier logging-leak fix for pre-init status/error paths, but they make the ownership less clear.

I pushed 1766f05 to simplify this:

  • configureBridgeLogging() is now only called from init() as the central setup point
  • syntaxBridgeSetVerbose() only reconfigures if logging was already initialized, so changing verbosity after init still works
  • pre-init/status-only failures now just return structured status errors instead of logging through Log::log() before initialization

Validation:

  • rebuilt SyntaxInterface
  • verified SyntaxInterface exports against jaspSyntax
  • ran the focused jaspSyntax desktop bridge contract test
  • smoke-tested the pre-init verbose = FALSE error path; it still captures zero native output

@JorisGoosen

Copy link
Copy Markdown
Contributor

It compiles and the code looks good now!

Im just missing some unittest(s) in Tests/

@JorisGoosen
JorisGoosen merged commit a4e5e4d into jasp-stats:development Jun 5, 2026
2 checks passed
boutinb added a commit to boutinb/jasp-desktop that referenced this pull request Jun 10, 2026
jasp-stats#6235 was a duplicate of jasp-stats#6249, but adds some small extra changes.
This commit add these extra changes
boutinb added a commit that referenced this pull request Jun 10, 2026
#6235 was a duplicate of #6249, but adds some small extra changes.
This commit add these extra changes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants