I built a journal app where the server classifies your emotions without ever reading your text.

The app already had end-to-end encryption. Every entry gets encrypted on-device before it leaves. The server stores opaque blobs and can’t read anything. But I wanted the app to do more than store text. I wanted it to understand what you’re feeling when you write.

The obvious approach, sending plaintext to an ML model, defeats the entire privacy story. Patricia Thaine put this well: NLP systems routinely process the most personal data we produce, and the gap between what users expect and what happens to their text on the server is wide. The usual answer is “trust us.” I wanted a better one.

That’s where Fully Homomorphic Encryption comes in. FHE lets you run computations on encrypted data without decrypting it first. The server processes your journal entry and returns an encrypted result. It never sees the input, never sees the output. You decrypt the result locally and get your emotion label. The math behind this is called a circuit, a series of operations compiled to work over encrypted integers instead of regular ones.

This matters beyond journal apps. Any product that handles sensitive user data, whether health, finance, or legal, faces the same tension between privacy and intelligence. FHE resolves it. The server can be useful without being trusted. Zama, the company behind Concrete ML, is building the tooling to make this practical.

Sounds great on paper. Getting it to run inside a Flutter app on iOS was a different story.

Finding the right emotions

Google’s GoEmotions dataset labels Reddit comments with 27 distinct emotions: admiration, amusement, anger, annoyance, approval, caring, confusion, curiosity, desire, disappointment, disapproval, disgust, embarrassment, excitement, fear, gratitude, grief, joy, love, nervousness, optimism, pride, realization, relief, remorse, sadness, surprise, and neutral.

Twenty-seven classes is too many. The model couldn’t distinguish between “annoyance” and “disapproval” reliably enough, and some emotions like grief and nervousness had too few training samples to learn from. I needed the classifier to be decisive, not subtle.

I collapsed them into five Ekman classes:

  • Joy absorbs 12 emotions: amusement, approval, excitement, gratitude, love, optimism, relief, admiration, desire, caring, pride
  • Sadness absorbs disappointment, embarrassment, grief, remorse, plus fear and nervousness (too few samples on their own)
  • Anger absorbs annoyance, disapproval, and disgust
  • Surprise absorbs realization, confusion, curiosity
  • Neutral stays as-is

This gave me a workable class distribution (~39% joy, 30% neutral, 13% anger, 10% surprise, 7% sadness).

Before the server can run inference on encrypted data, the model gets compiled into a circuit. Every operation becomes a series of encrypted integer computations. The complexity of that circuit depends on a parameter called n_bits, the number of bits each value gets quantized to. Fewer bits means a smaller, faster circuit. More bits means higher fidelity but exponentially more computation. Transformers are a poor fit here: softmax, layer normalization, and GELU are non-polynomial operations, and FHE circuits can only evaluate polynomials efficiently. Zama’s own sentiment analysis demo works around this by running BERT client-side in the clear, then classifying those embeddings with FHE-compiled XGBoost. That’s a valid approach, but bundling a transformer model inflates the app size considerably. I went with TF-IDF + dimensionality reduction + XGBoost instead, keeping the entire pipeline lightweight and the app bundle small. This is a proof of concept, not a chase for state-of-the-art NLP accuracy.

The ML pipeline

The feature pipeline compresses each journal entry into a float vector. Training looks like standard scikit-learn:

# Feature extraction
tfidf = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_tfidf = tfidf.fit_transform(texts)

svd = TruncatedSVD(n_components=50)
X_features = normalizer.fit_transform(svd.fit_transform(X_tfidf))

# Train with Concrete ML's FHE-compatible XGBoost
model = FHEXGBClassifier(n_bits=3, n_estimators=50, max_depth=3)
model.fit(X_features, labels)

 

I tried LogisticRegression first since it’s simpler, but Concrete ML’s version had issues with the multi-class configuration. XGBoost compiled to an FHE circuit without complaints.

The compilation step is where Concrete ML does its work. You call compile with a calibration dataset, and it quantizes the model and produces two deployment artifacts:

# Compile to FHE circuit
model.compile(calibration_data)

# Save server.zip (backend) + client.zip (device)
dev = FHEModelDev(path_dir="fhe_model", model=model)
dev.save()

 

The server.zip goes to the backend. The client.zip gets bundled into the Flutter app. It contains quantization parameters (how to convert floats to integers and back), circuit topology (what encryption keys the circuit needs), and model metadata. Zama’s tutorials walk through this well.

First attempt: Python sidecar

With the compiled model, Concrete ML gives you a Python FHEModelClient and FHEModelServer. The intended usage is straightforward:

client = FHEModelClient(path_dir="fhe_model")
server = FHEModelServer(path_dir="fhe_model")

# Client encrypts, server runs blind, client decrypts
eval_keys = client.get_serialized_evaluation_keys()
encrypted_input = client.quantize_encrypt_serialize(features)
encrypted_result = server.run(encrypted_input, eval_keys)
result = client.deserialize_decrypt_dequantize(encrypted_result)

 

My first architecture had a Python process running alongside the Flutter app, exposing this as a local HTTP service. The Flutter app sent requests to localhost:8001.

This worked on my laptop. But not compatible if I want a just-install-it phone app.

Going native with Rust and FFI

The core of Concrete ML runs on TFHE-rs, a Rust library. So I wrote a thin C FFI layer around it that Dart calls directly through Flutter’s FFI mechanism. The Rust side handles key generation, encryption, and decryption.

The hardest part was key generation. FHE keys aren’t generic. They’re shaped by the specific circuit they’ll be used with. The client.zip contains a file called client.specs.json that describes the circuit’s structure: how many encryption keys are needed, what dimensions they use, and what noise parameters keep the math secure. My Rust code parses that spec and generates keys that match.

The TF-IDF and SVD pipeline also had to leave Python behind. I rewrote the entire feature extraction in pure Dart, loading pre-exported binary assets (IDF weights, SVD components matrix) from the Flutter asset bundle.

Extracting it into a plugin

Once the native FHE client worked inside the app, the boundary between “FHE library” and “journal app” got messy. The app was parsing client.zip, managing key storage, handling quantization math, and calling FFI functions directly.

I pulled all of that into a standalone Flutter plugin called flutter_concrete. From the app’s perspective, the FHE flow mirrors the Python version closely:

// Setup: parse client.zip, generate or restore keys
await concrete.setup(clientZipBytes, keyStorage);

// Upload eval key to backend (once)
await backend.post('/fhe/key',
    data: {'evaluation_key_b64': concrete.serverKeyBase64});

// Vectorize text on-device (pure Dart TF-IDF + SVD)
final features = vectorizer.transform(journalText);

// Encrypt features
final ciphertext = concrete.quantizeAndEncrypt(features);

// Server runs inference on encrypted data
final encryptedResult = await backend.post('/fhe/predict',
    data: {'encrypted_input_b64': base64Encode(ciphertext)});

// Decrypt and interpret
final scores = concrete.decryptAndDequantize(base64Decode(encryptedResult));
final emotion = labels[scores.indexOf(scores.reduce(max))];

 

The plugin parses the client.zip internally, handles quantization, manages key persistence through a KeyStorage interface the app provides, and supports both encryption formats. Cargokit handles the Rust compilation automatically during flutter build. No manual build scripts.

What quantization costs you

FHE forces every value through quantization. The n_bits parameter controls how many bits each number gets. At 8 bits, you have 256 possible values per weight. At 3 bits, you have 8. The model’s decision boundaries get rounded to fit.

I ran experiments across different model sizes and quantization levels. The full results:

ConfigLSA dimsTreesDepthn_bitsPlain accFHE accDeltaCircuit opsKey sizeFHE inference
Small, 3-bit50503340.2%34.5%-5.7pp3,750108 MB~1 min
Small, 8-bit50503840.2%46.0%+5.8pp19,500854 MB~3.5 min
Deep, 3-bit50505342.7%37.0%-5.7pp15,750108 MB~4 min
Large, 3-bit2002003350.5%35.5%-15.0pp15,000108 MB~4 min
Large, 8-bit2002003850.5%53.5%+3.0pp78,000854 MB~20 min

A few patterns stood out. Higher bit precision consistently matters more than model size. Richer features and deeper trees help, but only when quantization can preserve the finer decision boundaries. At 3 bits, making the model bigger doesn’t pay off. At 8 bits, the FHE model actually matches or slightly exceeds the plain baseline (a regularization effect from quantization).

The 8-bit circuits are about 5x more complex and generate encryption keys roughly 8x larger. On a phone, 854 MB of key material is a problem. At 3 bits, the keys fit comfortably at 108 MB, but accuracy takes a real hit. Encrypted inference time scales with circuit complexity, from about a minute for the smallest 3-bit circuit to considerably longer for the large 8-bit one.

For comparison, Zama’s own sentiment analysis demo uses 3-bit XGBoost with 50 estimators and gets 85% accuracy on a 3-class task. But they feed it BERT embeddings (768 dimensions, computed in the clear), which are far richer than raw TF-IDF features. Our 5-class task with TF-IDF features is harder, and the accuracy reflects that.

What I’d do differently

Key generation takes 10-60 seconds on mobile. Cached via flutter_secure_storage so it’s a one-time cost, but that first-launch wait isn’t great.

The evaluation key upload is also heavy on first use. You might think the answer is caching it server-side in Redis, but the evaluation key is derived from the client’s secret key. Persisting it on the server conflicts with the E2EE model. You’re asking users to trust that the server won’t hold onto cryptographic material linked to their private key. The cleaner answer is re-uploading from the device when needed, which means optimizing that transfer path instead.

50% accuracy on 5 emotion classes isn’t impressive by NLP standards. But the point was never state-of-the-art classification. It was proving that a mobile app can get useful intelligence from a server that never sees its data. That part works. The accuracy ceiling will rise as FHE-compatible architectures improve, quantization gets smarter, and hardware catches up. The privacy guarantee is already real.

The plugin is on pub.dev as flutter_concrete ^0.4.0 if you want to try it yourself.