diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt
index e2a903d96e..1b570c822b 100644
--- a/.cspell-wordlist.txt
+++ b/.cspell-wordlist.txt
@@ -188,3 +188,8 @@ stringifying
hɛloʊ
wɜːld
bielik
+nemotron
+BIOES
+viterbi
+argmaxes
+unpadded
diff --git a/.eslintrc.js b/.eslintrc.js
index f95187b46b..8cb84b9ff8 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -13,6 +13,7 @@ const VALID_CATEGORIES = [
'Models - Semantic Segmentation',
'Models - Speech To Text',
'Models - Style Transfer',
+ 'Models - Privacy Filter',
'Models - Text Embeddings',
'Models - Text to Speech',
'Models - VLM',
diff --git a/.gitignore b/.gitignore
index d481899882..86ce3a9042 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,3 +103,7 @@ packages/react-native-executorch/common/rnexecutorch/tests/integration/assets/mo
Makefile
*.pte
+.agents
+.claude
+skills-lock.json
+
diff --git a/apps/llm/app/_layout.tsx b/apps/llm/app/_layout.tsx
index e5dfc198a5..fc28a9d1b1 100644
--- a/apps/llm/app/_layout.tsx
+++ b/apps/llm/app/_layout.tsx
@@ -146,6 +146,14 @@ export default function _layout() {
headerTitleStyle: { color: ColorPalette.primary },
}}
/>
+
);
diff --git a/apps/llm/app/index.tsx b/apps/llm/app/index.tsx
index c7d5bae220..72358ae72c 100644
--- a/apps/llm/app/index.tsx
+++ b/apps/llm/app/index.tsx
@@ -41,6 +41,12 @@ export default function Home() {
>
Multimodal LLM (VLM)
+ router.navigate('privacy_filter/')}
+ >
+ Privacy Filter (PII)
+
);
diff --git a/apps/llm/app/privacy_filter/index.tsx b/apps/llm/app/privacy_filter/index.tsx
new file mode 100644
index 0000000000..e6541dfe52
--- /dev/null
+++ b/apps/llm/app/privacy_filter/index.tsx
@@ -0,0 +1,251 @@
+import { useMemo, useState } from 'react';
+import {
+ ActivityIndicator,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { useIsFocused } from '@react-navigation/native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import {
+ PiiEntity,
+ PRIVACY_FILTER_NEMOTRON,
+ PRIVACY_FILTER_OPENAI,
+ PrivacyFilterModelSources,
+ usePrivacyFilter,
+} from 'react-native-executorch';
+import ColorPalette from '../../colors';
+import { ModelOption, ModelPicker } from '../../components/ModelPicker';
+import {
+ buildSegments,
+ colorForLabel,
+ matchEntities,
+} from '../../utils/piiMatching';
+
+/* cspell:disable */
+// Sample tuned for the OpenAI base model — exercises the 8 entity types it
+// recognizes (person, email, phone, account_number, address, date, url,
+// secret).
+const OPENAI_SAMPLE = `My name is Sarah Chen and I work as a senior engineer at Acme Corp. You can reach me at sarah.chen@acmecorp.io or call my direct line at (415) 923-0847. For billing inquiries, my account number is ACC-8821-4490-3371.
+
+I've been living at 17 Birchwood Lane, Portland, OR 97201 since October 3rd, 2019. Before that I was at 8 Rue de Rivoli, Paris, 75001, France. My personal website is https://sarahchen.dev and my GitHub is https://github.com/schen-eng. Feel free to connect — I usually respond within a business day.
+
+My date of birth is June 12, 1991, and my backup email is s.chen.personal@gmail.com in case the primary address is unreachable. This message also contains a confidential API key: sk-T93kXpLm2NvBqR7dYwZ4. Please do not share it outside the team. You can also reach my colleague James Okonkwo at j.okonkwo@acmecorp.io or at his mobile +44 7911 123456.`;
+// Sample tuned for the OpenMed Nemotron model — covers categories the base
+// OpenAI model doesn't have (medical, financial, technical, demographic).
+
+const NEMOTRON_SAMPLE = `Patient intake for Maria Lopez, female, age 47, blood type O+, born 1978-05-12. MRN 994-2210-AB; health plan beneficiary number HPBN-552-9931 with Aetna. SSN 412-55-7821, national ID DNI 88-7762-X. Primary occupation: registered nurse, currently employed full-time at Mercy General. Religion: Catholic; political view: independent.
+
+Reach her at maria.lopez@example.com or +1 (415) 555-0142. Mailing address: 84 Cedar Hill Road, Apt 3B, Berkeley, CA 94703, United States. Vehicle plate 7XKL922; driver license CA-D1294883.
+
+Payment for last visit: Visa ending 4992-1133-7820-4419, expires 11/28, CVV 884. Bank routing 021000089, SWIFT BIC CHASUS33. Employer EIN tax ID 47-3320118. Customer ID CUST-553201, employee ID EMP-A0093.
+
+Workstation MAC 3C:22:FB:8E:01:9A, IPv4 10.0.42.118, device IMEI 359888061234560. Service account API key sk-live-Tn8x3pLm2NvBqR7dYwZ4QF, password Hunter2!Spring. Session cookie sid=eyJ1c2VyIjoiOTk0MjIxMCJ9.`;
+/* cspell:enable */
+
+const MODEL_OPTIONS: ModelOption[] = [
+ { label: 'OpenAI Privacy Filter (8 entities)', value: PRIVACY_FILTER_OPENAI },
+ {
+ label: 'OpenMed Nemotron (55 entities)',
+ value: PRIVACY_FILTER_NEMOTRON,
+ },
+];
+
+// Pick the right sample to display/run based on the active model.
+function sampleFor(model: PrivacyFilterModelSources): string {
+ return model.modelName === PRIVACY_FILTER_NEMOTRON.modelName
+ ? NEMOTRON_SAMPLE
+ : OPENAI_SAMPLE;
+}
+
+function HighlightedText({
+ source,
+ entities,
+}: {
+ source: string;
+ entities: PiiEntity[];
+}) {
+ const segments = useMemo(
+ () => buildSegments(source, matchEntities(source, entities)),
+ [source, entities]
+ );
+ return (
+
+ {segments.map((seg, i) =>
+ seg.label ? (
+
+ {seg.text}
+
+ ) : (
+ {seg.text}
+ )
+ )}
+
+ );
+}
+
+function PrivacyFilterScreen() {
+ const { bottom } = useSafeAreaInsets();
+ const [entities, setEntities] = useState(null);
+ const [runError, setRunError] = useState(null);
+ const [inferenceMs, setInferenceMs] = useState(null);
+ const [selectedModel, setSelectedModel] = useState(
+ PRIVACY_FILTER_OPENAI
+ );
+
+ const filter = usePrivacyFilter({ model: selectedModel });
+ const sampleText = sampleFor(selectedModel);
+
+ const onRun = async () => {
+ setRunError(null);
+ setEntities(null);
+ setInferenceMs(null);
+ const startedAt = Date.now();
+ try {
+ const result = await filter.generate(sampleText);
+ const elapsed = Date.now() - startedAt;
+ setInferenceMs(elapsed);
+ setEntities(result);
+ } catch (e) {
+ setRunError(e instanceof Error ? e.message : String(e));
+ }
+ };
+
+ const disabled = !filter.isReady || filter.isGenerating;
+
+ return (
+
+ {
+ setEntities(null);
+ setRunError(null);
+ setInferenceMs(null);
+ setSelectedModel(m);
+ }}
+ label="Model"
+ disabled={filter.isGenerating}
+ />
+
+ {filter.error && (
+
+
+ Load error: {filter.error.message}
+
+
+ )}
+
+ {!filter.isReady && !filter.error && (
+
+
+
+ Downloading model…{' '}
+ {Math.round((filter.downloadProgress ?? 0) * 100)}%
+
+
+ )}
+
+
+ {entities ? (
+
+ ) : (
+ {sampleText}
+ )}
+
+
+
+ {filter.isGenerating ? (
+
+ ) : (
+
+ Detect PII
+ {inferenceMs !== null && ` · ${inferenceMs} ms`}
+
+ )}
+
+
+ {runError && (
+
+ Run error: {runError}
+
+ )}
+
+ );
+}
+
+export default function PrivacyFilterScreenWrapper() {
+ const isFocused = useIsFocused();
+ return isFocused ? : null;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 16,
+ backgroundColor: '#fff',
+ gap: 10,
+ },
+ textBox: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ borderRadius: 8,
+ padding: 10,
+ },
+ sampleText: {
+ fontSize: 13,
+ color: '#222',
+ lineHeight: 19,
+ },
+ highlight: {
+ fontWeight: '600',
+ borderRadius: 3,
+ },
+ runButton: {
+ backgroundColor: ColorPalette.primary,
+ borderRadius: 8,
+ paddingVertical: 12,
+ alignItems: 'center',
+ },
+ runButtonText: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ buttonDisabled: {
+ opacity: 0.5,
+ },
+ centerBlock: {
+ alignItems: 'center',
+ gap: 6,
+ paddingVertical: 8,
+ },
+ muted: {
+ color: '#666',
+ fontSize: 12,
+ },
+ errorBanner: {
+ backgroundColor: '#fdecea',
+ borderColor: '#f5c6cb',
+ borderWidth: 1,
+ borderRadius: 6,
+ padding: 8,
+ },
+ errorText: {
+ color: '#a94442',
+ fontSize: 12,
+ },
+});
diff --git a/apps/llm/utils/piiMatching.ts b/apps/llm/utils/piiMatching.ts
new file mode 100644
index 0000000000..1f4a8adf1e
--- /dev/null
+++ b/apps/llm/utils/piiMatching.ts
@@ -0,0 +1,132 @@
+import { PiiEntity } from 'react-native-executorch';
+
+/**
+ * A detected entity span pinned to a character range in the source text.
+ */
+export interface EntityMatch {
+ start: number;
+ end: number;
+ label: string;
+}
+
+const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+// Word-boundary-anchored search so e.g. "John" doesn't match the "John"
+// inside "Johnson".
+function findWordBounded(source: string, needle: string, from: number): number {
+ const re = new RegExp(`\\b${escapeRegex(needle)}\\b`);
+ const match = re.exec(source.slice(from));
+ return match ? from + match.index : -1;
+}
+
+/**
+ * Map detected entities (which carry decoded text only) onto character
+ * ranges in the source text by scanning forward.
+ *
+ * The native runner's `text` field is the BPE-detokenized form of the
+ * entity, which often differs from the source by whitespace, punctuation
+ * spacing, or stripped specials. Strategy:
+ * 1) Try a word-bounded match for the whole entity from the cursor onward.
+ * 2) On miss, fall back to each non-trivial word from the entity text
+ * individually so we still highlight most of the span.
+ *
+ * Order-preserving: cursor advances after each successful match so
+ * duplicate strings resolve left-to-right.
+ * @param source - The full text the model was run against.
+ * @param entities - Entities returned by the native runner.
+ * @returns Sorted, non-overlapping char-range matches.
+ */
+export function matchEntities(
+ source: string,
+ entities: PiiEntity[]
+): EntityMatch[] {
+ const matches: EntityMatch[] = [];
+ let cursor = 0;
+ for (const e of entities) {
+ if (!e.text) continue;
+ const exact = findWordBounded(source, e.text, cursor);
+ if (exact !== -1) {
+ matches.push({
+ start: exact,
+ end: exact + e.text.length,
+ label: e.label,
+ });
+ cursor = exact + e.text.length;
+ continue;
+ }
+ const words = e.text.split(/\s+/).filter((w) => w.length > 1);
+ let localCursor = cursor;
+ for (const w of words) {
+ const idx = findWordBounded(source, w, localCursor);
+ if (idx === -1) continue;
+ matches.push({ start: idx, end: idx + w.length, label: e.label });
+ localCursor = idx + w.length;
+ }
+ if (localCursor > cursor) cursor = localCursor;
+ }
+ matches.sort((a, b) => a.start - b.start);
+ return matches;
+}
+
+export interface Segment {
+ text: string;
+ label: string | null;
+}
+
+/**
+ * Slice the source text into alternating plain / labeled runs based on the
+ * matched entity ranges. Overlaps are dropped (the earlier match wins).
+ * @param source - The full text to slice.
+ * @param matches - Char-range matches from {@link matchEntities}.
+ * @returns An ordered array of segments covering the entire source.
+ */
+export function buildSegments(
+ source: string,
+ matches: EntityMatch[]
+): Segment[] {
+ const segments: Segment[] = [];
+ let pos = 0;
+ for (const m of matches) {
+ if (m.start < pos) continue;
+ if (m.start > pos) {
+ segments.push({ text: source.slice(pos, m.start), label: null });
+ }
+ segments.push({ text: source.slice(m.start, m.end), label: m.label });
+ pos = m.end;
+ }
+ if (pos < source.length) {
+ segments.push({ text: source.slice(pos), label: null });
+ }
+ return segments;
+}
+
+/**
+ * Pastel color palette + stable label-to-color mapping. Same label always
+ * gets the same color across renders / runs.
+ */
+const PALETTE = [
+ '#ffd4a8',
+ '#b8e1ff',
+ '#d4c5f9',
+ '#c3e8c3',
+ '#ffe8b8',
+ '#f8c6c6',
+ '#e3c8a8',
+ '#ff9aa2',
+ '#b6e3d4',
+ '#ffd6e0',
+ '#cdb4db',
+ '#ffc8a2',
+ '#a2d2ff',
+ '#bde0fe',
+ '#fcd5ce',
+];
+
+export function colorForLabel(label: string): string {
+ let hash = 0;
+ for (let i = 0; i < label.length; i++) {
+ // eslint-disable-next-line no-bitwise
+ hash = (hash * 31 + label.charCodeAt(i)) | 0;
+ }
+ return PALETTE[Math.abs(hash) % PALETTE.length] as string;
+}
diff --git a/docs/docs/03-hooks/01-natural-language-processing/usePrivacyFilter.md b/docs/docs/03-hooks/01-natural-language-processing/usePrivacyFilter.md
new file mode 100644
index 0000000000..c0155dfbbf
--- /dev/null
+++ b/docs/docs/03-hooks/01-natural-language-processing/usePrivacyFilter.md
@@ -0,0 +1,151 @@
+---
+title: usePrivacyFilter
+keywords:
+ [
+ privacy filter,
+ pii detection,
+ pii,
+ personally identifiable information,
+ privacy,
+ redaction,
+ react native,
+ executorch,
+ ai,
+ machine learning,
+ on-device,
+ mobile ai,
+ ]
+description: "Detect personally identifiable information (PII) in text on-device with React Native ExecuTorch's usePrivacyFilter hook."
+---
+
+Privacy Filter is a token-level model that scans text for personally identifiable information (PII) — names, emails, phone numbers, addresses, SSNs, secrets, and more — and returns the detected spans together with the entity type. Inference runs entirely on-device, so the input text never leaves the user's phone.
+
+:::info
+It is recommended to use models provided by us, which are available at our [Hugging Face repository](https://huggingface.co/collections/software-mansion/privacy-filter). You can also use [constants](https://github.com/software-mansion/react-native-executorch/blob/main/packages/react-native-executorch/src/constants/modelUrls.ts) shipped with our library.
+:::
+
+## API Reference
+
+- For detailed API Reference for `usePrivacyFilter` see: [`usePrivacyFilter` API Reference](../../06-api-reference/functions/usePrivacyFilter.md).
+- For all Privacy Filter models available out-of-the-box in React Native ExecuTorch see: [Privacy Filter Models](../../06-api-reference/index.md#models---privacy-filter).
+
+## High Level Overview
+
+```typescript
+import {
+ usePrivacyFilter,
+ PRIVACY_FILTER_OPENAI,
+} from 'react-native-executorch';
+
+const model = usePrivacyFilter({ model: PRIVACY_FILTER_OPENAI });
+
+try {
+ const entities = await model.generate(
+ 'My name is Sarah Chen and my email is sarah@example.com.'
+ );
+ console.log(entities);
+ // [
+ // { label: 'private_person', text: 'Sarah Chen', startToken: 3, endToken: 5 },
+ // { label: 'private_email', text: 'sarah@example.com', startToken: 11, endToken: 14 },
+ // ]
+} catch (error) {
+ console.error(error);
+}
+```
+
+### Arguments
+
+`usePrivacyFilter` takes [`PrivacyFilterProps`](../../06-api-reference/interfaces/PrivacyFilterProps.md) that consists of:
+
+- `model` of type [`PrivacyFilterModelSources`](../../06-api-reference/interfaces/PrivacyFilterModelSources.md) containing the model source, tokenizer source, and BIOES label list.
+- An optional flag [`preventLoad`](../../06-api-reference/interfaces/PrivacyFilterProps.md#preventload) which prevents auto-loading of the model.
+
+You need more details? Check the following resources:
+
+- For detailed information about `usePrivacyFilter` arguments check this section: [`usePrivacyFilter` arguments](../../06-api-reference/functions/usePrivacyFilter.md#parameters).
+- For all Privacy Filter models available out-of-the-box in React Native ExecuTorch see: [Privacy Filter Models](../../06-api-reference/index.md#models---privacy-filter).
+- For more information on loading resources, take a look at [loading models](../../01-fundamentals/02-loading-models.md) page.
+
+### Returns
+
+`usePrivacyFilter` returns an object called `PrivacyFilterType` containing a `generate` function for running detection. To get more details please read: [`PrivacyFilterType` API Reference](../../06-api-reference/interfaces/PrivacyFilterType.md).
+
+## Running the model
+
+To run the model, call the [`generate`](../../06-api-reference/interfaces/PrivacyFilterType.md#generate) method with the text you want to scan. The method returns a promise that resolves to an array of [`PiiEntity`](../../06-api-reference/interfaces/PiiEntity.md) objects, each describing one detected span (`label`, decoded `text`, and inclusive `startToken` / exclusive `endToken` indices into the tokenized input).
+
+Inputs are processed in sliding windows with 50% overlap (the window size matches the model's exported `forward` input shape), so there is no length limit — long documents are scanned end-to-end without truncation.
+
+:::note
+Token indices in returned entities are positions in the tokenizer's output (the unpadded `encode()` stream), not character offsets in the original string. Use the entity's decoded `text` field if you want to display or redact spans verbatim.
+:::
+
+### Tuning precision and recall
+
+Both built-in models ship with neutral, validity-only Viterbi decoding by default. If you want to shift the precision/recall tradeoff, pass an optional [`viterbiBiases`](../../06-api-reference/interfaces/PrivacyFilterModelSources.md#viterbibiases) object — six floats matching the operating-point schema in OpenAI's `viterbi_calibration.json`. Negative `backgroundToStart` makes the decoder enter spans more eagerly (higher recall); positive `backgroundStay` keeps it in the background label more often (higher precision).
+
+## Example
+
+```tsx
+import React, { useState } from 'react';
+import { Button, Text, View, TextInput, ScrollView } from 'react-native';
+import {
+ usePrivacyFilter,
+ PRIVACY_FILTER_OPENAI,
+ PiiEntity,
+} from 'react-native-executorch';
+
+export default function App() {
+ const model = usePrivacyFilter({ model: PRIVACY_FILTER_OPENAI });
+ const [text, setText] = useState(
+ 'My name is Sarah Chen and you can reach me at sarah.chen@example.com.'
+ );
+ const [entities, setEntities] = useState([]);
+
+ const handleScan = async () => {
+ if (!model.isReady) {
+ console.error('Privacy Filter model is not loaded yet.');
+ return;
+ }
+ try {
+ const detected = await model.generate(text);
+ setEntities(detected);
+ } catch (error) {
+ console.error('Error during running Privacy Filter model', error);
+ }
+ };
+
+ return (
+
+
+
+ {entities.map((entity, idx) => (
+
+
+ {entity.label}:{' '}
+ {entity.text}
+
+
+ ))}
+
+ );
+}
+```
+
+## Supported models
+
+| Model | Categories | Description |
+| ---------------------------------------------------------------------------------------------- | :--------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [openai/privacy-filter](https://huggingface.co/openai/privacy-filter) | 8 | OpenAI's base PII detector. Covers names, emails, phone numbers, addresses, dates of birth, URLs, and generic secrets / API keys. |
+| [OpenMed/privacy-filter-nemotron](https://huggingface.co/OpenMed/privacy-filter-nemotron-base) | 55+ | Fine-tune of the base model with a much wider label space — adds medical, financial, demographic, technical, and government-document categories on top of the base 8. |
+
+**`Categories`** — The number of distinct entity types the model can emit. Both models share the same backbone (256-token windows, 128-token banded attention) and tokenizer (o200k); they differ only in the BIOES label space.
diff --git a/docs/docs/04-typescript-api/01-natural-language-processing/PrivacyFilterModule.md b/docs/docs/04-typescript-api/01-natural-language-processing/PrivacyFilterModule.md
new file mode 100644
index 0000000000..a862bce3bb
--- /dev/null
+++ b/docs/docs/04-typescript-api/01-natural-language-processing/PrivacyFilterModule.md
@@ -0,0 +1,57 @@
+---
+title: PrivacyFilterModule
+---
+
+TypeScript API implementation of the [usePrivacyFilter](../../03-hooks/01-natural-language-processing/usePrivacyFilter.md) hook.
+
+## API Reference
+
+- For detailed API Reference for `PrivacyFilterModule` see: [`PrivacyFilterModule` API Reference](../../06-api-reference/classes/PrivacyFilterModule.md).
+- For all Privacy Filter models available out-of-the-box in React Native ExecuTorch see: [Privacy Filter Models](../../06-api-reference/index.md#models---privacy-filter).
+
+## High Level Overview
+
+```typescript
+import {
+ PrivacyFilterModule,
+ PRIVACY_FILTER_OPENAI,
+} from 'react-native-executorch';
+
+const model = await PrivacyFilterModule.fromModelName(
+ PRIVACY_FILTER_OPENAI,
+ (progress) => console.log(progress)
+);
+
+const entities = await model.generate('My name is Sarah Chen.');
+```
+
+### Methods
+
+All methods of `PrivacyFilterModule` are explained in details here: [`PrivacyFilterModule` API Reference](../../06-api-reference/classes/PrivacyFilterModule.md)
+
+## Loading the model
+
+To create a ready-to-use instance, call the static [`fromModelName`](../../06-api-reference/classes/PrivacyFilterModule.md#frommodelname) factory with the following parameters:
+
+- `namedSources` — Object containing:
+ - `modelName` — Model name identifier.
+ - `modelSource` — Location of the `.pte` model binary.
+ - `tokenizerSource` — Location of the `tokenizer.json` file.
+ - `labelNames` — BIOES label list. Index 0 must be `"O"`; the rest must follow the model's `id2label` mapping exactly.
+ - `viterbiBiases` (optional) — Six-field bias struct that shifts the decoder's precision/recall tradeoff. Defaults to neutral (validity-only Viterbi).
+
+- `onDownloadProgress` — Optional callback to track download progress (value between 0 and 1).
+
+The factory returns a promise that resolves to a loaded `PrivacyFilterModule` instance.
+
+For custom-exported models, use [`fromCustomModel`](../../06-api-reference/classes/PrivacyFilterModule.md#fromcustommodel) instead — it takes the same fields as positional arguments and is convenient when you only have the raw resource locations.
+
+For more information on loading resources, take a look at [loading models](../../01-fundamentals/02-loading-models.md) page.
+
+## Running the model
+
+To run the model, call the [`generate`](../../06-api-reference/classes/PrivacyFilterModule.md#generate) method on the module object with the text you want to scan. The method returns a promise that resolves to an array of detected PII entity spans. Long inputs are processed in sliding windows with 50% overlap (window size derived from the model's exported `forward` input shape); no truncation.
+
+## Managing memory
+
+The module is a regular JavaScript object, and as such its lifespan will be managed by the garbage collector. In most cases this should be enough, and you should not worry about freeing the memory of the module yourself, but in some cases you may want to release the memory occupied by the module before the garbage collector steps in. In this case use the method [`delete`](../../06-api-reference/classes/PrivacyFilterModule.md#delete) on the module object you will no longer use, and want to remove from the memory. Note that you cannot use [`generate`](../../06-api-reference/classes/PrivacyFilterModule.md#generate) after [`delete`](../../06-api-reference/classes/PrivacyFilterModule.md#delete) unless you load the module again.
diff --git a/packages/react-native-executorch/common/rnexecutorch/RnExecutorchInstaller.cpp b/packages/react-native-executorch/common/rnexecutorch/RnExecutorchInstaller.cpp
index da75ab951c..22add11719 100644
--- a/packages/react-native-executorch/common/rnexecutorch/RnExecutorchInstaller.cpp
+++ b/packages/react-native-executorch/common/rnexecutorch/RnExecutorchInstaller.cpp
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -98,6 +99,11 @@ void RnExecutorchInstaller::injectJSIBindings(
RnExecutorchInstaller::loadModel(
jsiRuntime, jsCallInvoker, "loadLLM"));
+ jsiRuntime->global().setProperty(
+ *jsiRuntime, "loadPrivacyFilter",
+ RnExecutorchInstaller::loadModel(
+ jsiRuntime, jsCallInvoker, "loadPrivacyFilter"));
+
jsiRuntime->global().setProperty(
*jsiRuntime, "loadOCR",
RnExecutorchInstaller::loadModel(
diff --git a/packages/react-native-executorch/common/rnexecutorch/host_objects/JsiConversions.h b/packages/react-native-executorch/common/rnexecutorch/host_objects/JsiConversions.h
index 7b389d45b6..c9aca42491 100644
--- a/packages/react-native-executorch/common/rnexecutorch/host_objects/JsiConversions.h
+++ b/packages/react-native-executorch/common/rnexecutorch/host_objects/JsiConversions.h
@@ -20,6 +20,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -535,6 +536,24 @@ getJsiValue(const std::vector
return jsiSegments;
}
+inline jsi::Value getJsiValue(
+ const std::vector &entities,
+ jsi::Runtime &runtime) {
+ auto jsiEntities = jsi::Array(runtime, entities.size());
+ for (size_t i = 0; i < entities.size(); i++) {
+ const auto &e = entities[i];
+ auto obj = jsi::Object(runtime);
+ obj.setProperty(runtime, "label",
+ jsi::String::createFromUtf8(runtime, e.label));
+ obj.setProperty(runtime, "text",
+ jsi::String::createFromUtf8(runtime, e.text));
+ obj.setProperty(runtime, "startToken", e.startToken);
+ obj.setProperty(runtime, "endToken", e.endToken);
+ jsiEntities.setValueAtIndex(runtime, i, obj);
+ }
+ return jsiEntities;
+}
+
inline jsi::Value getJsiValue(const Segment &seg, jsi::Runtime &runtime) {
jsi::Object obj(runtime);
obj.setProperty(runtime, "start", seg.start);
diff --git a/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.cpp b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.cpp
new file mode 100644
index 0000000000..042446bb34
--- /dev/null
+++ b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.cpp
@@ -0,0 +1,216 @@
+#include "PrivacyFilter.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+namespace rnexecutorch::models::privacy_filter {
+
+using executorch::aten::ScalarType;
+using executorch::extension::make_tensor_ptr;
+using executorch::extension::TensorPtr;
+using executorch::runtime::EValue;
+
+namespace {
+
+// o200k tokenizer: <|endoftext|> (id 199999) doubles as pad and eos.
+constexpr int64_t kPadTokenId = 199999;
+
+} // namespace
+
+PrivacyFilter::PrivacyFilter(const std::string &modelSource,
+ const std::string &tokenizerSource,
+ std::vector labelNames,
+ std::vector viterbiBiases,
+ std::shared_ptr callInvoker)
+ : BaseModel(modelSource, callInvoker),
+ tokenizer_(
+ std::make_unique(tokenizerSource, callInvoker)),
+ labelNames_(std::move(labelNames)), seqLen_(0) {
+ if (labelNames_.empty() || labelNames_[0] != "O") {
+ throw RnExecutorchError(
+ RnExecutorchErrorCode::UnknownError,
+ "PrivacyFilter requires a non-empty labelNames vector "
+ "(must include 'O' at index 0).");
+ }
+ if (!viterbiBiases.empty() && viterbiBiases.size() != 6) {
+ throw RnExecutorchError(RnExecutorchErrorCode::UnknownError,
+ "PrivacyFilter viterbiBiases must be empty or "
+ "contain exactly 6 floats.");
+ }
+ if (viterbiBiases.size() == 6) {
+ biases_.backgroundStay = viterbiBiases[0];
+ biases_.backgroundToStart = viterbiBiases[1];
+ biases_.endToBackground = viterbiBiases[2];
+ biases_.endToStart = viterbiBiases[3];
+ biases_.insideToContinue = viterbiBiases[4];
+ biases_.insideToEnd = viterbiBiases[5];
+ }
+ auto inputShapes = getAllInputShapes();
+ if (inputShapes.empty() || inputShapes[0].size() < 2 ||
+ inputShapes[0][1] < 2) {
+ throw RnExecutorchError(RnExecutorchErrorCode::WrongDimensions,
+ "PrivacyFilter: expected forward input shape "
+ "[1, seq_len] with seq_len >= 2.");
+ }
+ seqLen_ = inputShapes[0][1];
+ grammar_ = viterbi::buildGrammar(labelNames_, biases_);
+}
+
+std::string PrivacyFilter::labelEntityType(int32_t labelId) const {
+ if (labelId <= 0 || std::cmp_greater_equal(labelId, labelNames_.size())) {
+ return "";
+ }
+ const auto &name = labelNames_[static_cast(labelId)];
+ const auto dashPos = name.find('-');
+ if (dashPos == std::string::npos) {
+ return "";
+ }
+ return name.substr(dashPos + 1);
+}
+
+void PrivacyFilter::unload() noexcept {
+ std::scoped_lock lock(inference_mutex_);
+ BaseModel::unload();
+}
+
+void PrivacyFilter::runWindow(std::vector &paddedInputIds,
+ std::vector &paddedAttentionMask,
+ int32_t absStart, int32_t validLen,
+ int32_t writeFromOffset, int32_t writeToOffset,
+ std::vector &outLabels) {
+ if (validLen <= 0) {
+ return;
+ }
+
+ std::vector idsShape = {1, seqLen_};
+ auto inputIdsTensor =
+ make_tensor_ptr(idsShape, paddedInputIds.data(), ScalarType::Long);
+ auto attentionMaskTensor =
+ make_tensor_ptr(idsShape, paddedAttentionMask.data(), ScalarType::Long);
+
+ auto forwardResult =
+ BaseModel::forward({*inputIdsTensor, *attentionMaskTensor});
+ if (!forwardResult.ok()) {
+ throw RnExecutorchError(forwardResult.error(),
+ "The model's forward function did not succeed. "
+ "Ensure the model input is correct.");
+ }
+ auto &out = forwardResult.get();
+ if (out.empty()) {
+ throw RnExecutorchError(RnExecutorchErrorCode::UnknownError,
+ "PrivacyFilter: forward returned no outputs");
+ }
+
+ const auto &logitsTensor = out[0].toTensor();
+ const float *logits = logitsTensor.const_data_ptr();
+
+ auto path = viterbi::decode(logits, validLen, grammar_);
+
+ const int32_t end = std::min(writeToOffset, validLen);
+ std::copy(path.begin() + writeFromOffset, path.begin() + end,
+ outLabels.begin() + absStart + writeFromOffset);
+}
+
+std::vector PrivacyFilter::generate(std::string text) {
+ std::scoped_lock lock(inference_mutex_);
+
+ if (!module_) {
+ throw RnExecutorchError(RnExecutorchErrorCode::ModuleNotLoaded,
+ "PrivacyFilter is not loaded");
+ }
+
+ auto rawIds = tokenizer_->encode(text);
+ const int32_t totalTokens = static_cast(rawIds.size());
+
+ std::vector predictedLabels(static_cast(totalTokens), 0);
+
+ const int32_t stride = seqLen_ / 2;
+ const int32_t edgeMargin = seqLen_ / 4;
+ for (int32_t windowStart = 0; windowStart < totalTokens;
+ windowStart += stride) {
+ const int32_t validLen = std::min(seqLen_, totalTokens - windowStart);
+
+ std::vector paddedInputIds(static_cast(seqLen_),
+ kPadTokenId);
+ std::vector paddedAttentionMask(static_cast(seqLen_), 0);
+ for (int32_t i = 0; i < validLen; ++i) {
+ paddedInputIds[static_cast(i)] =
+ static_cast(rawIds[static_cast(windowStart + i)]);
+ paddedAttentionMask[static_cast(i)] = 1;
+ }
+
+ const bool isFirst = windowStart == 0;
+ const bool isLast = windowStart + seqLen_ >= totalTokens;
+ int32_t writeFrom = isFirst ? 0 : edgeMargin;
+ int32_t writeTo = isLast ? validLen : seqLen_ - edgeMargin;
+
+ runWindow(paddedInputIds, paddedAttentionMask, windowStart, validLen,
+ writeFrom, writeTo, predictedLabels);
+
+ if (isLast) {
+ break;
+ }
+ }
+
+ struct Span {
+ int32_t start;
+ int32_t end; // exclusive
+ std::string entity;
+ };
+ std::vector spans;
+ int32_t i = 0;
+ while (i < totalTokens) {
+ const auto entity =
+ labelEntityType(predictedLabels[static_cast(i)]);
+ if (entity.empty()) {
+ ++i;
+ continue;
+ }
+ int32_t j = i + 1;
+ while (j < totalTokens &&
+ labelEntityType(predictedLabels[static_cast(j)]) == entity) {
+ ++j;
+ }
+ spans.emplace_back(i, j, entity);
+ i = j;
+ }
+
+ std::vector entities;
+ entities.reserve(spans.size());
+ for (const auto &span : spans) {
+ std::vector slice;
+ slice.reserve(static_cast(span.end - span.start));
+ for (int32_t k = span.start; k < span.end; ++k) {
+ slice.emplace_back(rawIds[static_cast(k)]);
+ }
+ std::string decoded;
+ try {
+ decoded = tokenizer_->decode(slice, /*skipSpecialTokens=*/true);
+ } catch (...) {
+ }
+ constexpr auto notSpace = [](unsigned char c) { return !std::isspace(c); };
+ auto left = std::ranges::find_if(decoded, notSpace);
+ auto right =
+ std::ranges::find_if(decoded.rbegin(), decoded.rend(), notSpace).base();
+ if (left < right) {
+ decoded.assign(left, right);
+ } else {
+ decoded.clear();
+ }
+
+ entities.emplace_back(span.entity, std::move(decoded), span.start,
+ span.end);
+ }
+ return entities;
+}
+
+} // namespace rnexecutorch::models::privacy_filter
diff --git a/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.h b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.h
new file mode 100644
index 0000000000..880a9709f1
--- /dev/null
+++ b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/PrivacyFilter.h
@@ -0,0 +1,54 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace rnexecutorch {
+namespace models::privacy_filter {
+using namespace facebook;
+
+class PrivacyFilter final : public BaseModel {
+public:
+ PrivacyFilter(const std::string &modelSource,
+ const std::string &tokenizerSource,
+ std::vector labelNames,
+ std::vector viterbiBiases,
+ std::shared_ptr callInvoker);
+
+ [[nodiscard("Registered non-void function")]] std::vector
+ generate(std::string text);
+
+ void unload() noexcept;
+
+private:
+ void runWindow(std::vector &paddedInputIds,
+ std::vector &paddedAttentionMask, int32_t absStart,
+ int32_t validLen, int32_t writeFromOffset,
+ int32_t writeToOffset, std::vector &outLabels);
+
+ std::string labelEntityType(int32_t labelId) const;
+
+ mutable std::mutex inference_mutex_;
+ std::unique_ptr tokenizer_;
+ std::vector labelNames_;
+ viterbi::Biases biases_;
+ viterbi::Grammar grammar_;
+ int32_t seqLen_;
+};
+
+} // namespace models::privacy_filter
+
+REGISTER_CONSTRUCTOR(models::privacy_filter::PrivacyFilter, std::string,
+ std::string, std::vector, std::vector,
+ std::shared_ptr);
+} // namespace rnexecutorch
diff --git a/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Types.h b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Types.h
new file mode 100644
index 0000000000..84a3a5d711
--- /dev/null
+++ b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Types.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#include
+#include
+
+namespace rnexecutorch::models::privacy_filter::types {
+
+struct PiiEntity {
+ std::string label;
+ std::string text;
+ int32_t startToken;
+ int32_t endToken;
+};
+
+} // namespace rnexecutorch::models::privacy_filter::types
diff --git a/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.cpp b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.cpp
new file mode 100644
index 0000000000..214ddbbea6
--- /dev/null
+++ b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.cpp
@@ -0,0 +1,153 @@
+#include "Viterbi.h"
+
+#include
+#include
+#include
+
+namespace rnexecutorch::models::privacy_filter::viterbi {
+
+namespace {
+
+constexpr float kNegInf = -1e30f;
+
+struct LabelRole {
+ char prefix; // 'O', 'B', 'I', 'E', 'S'
+ std::string entity;
+};
+
+LabelRole classifyLabel(const std::string &name) {
+ if (name == "O" || name.empty()) {
+ return {'O', ""};
+ }
+ if (name.size() < 2 || name[1] != '-') {
+ return {'O', ""};
+ }
+ return {name[0], name.substr(2)};
+}
+
+bool isValidTransition(const LabelRole &prev, const LabelRole &nxt) {
+ // BIOES grammar:
+ // O / E-X / S-X -> O | B-* | S-*
+ // B-X / I-X -> I-X | E-X (same entity type)
+ if (prev.prefix == 'O' || prev.prefix == 'E' || prev.prefix == 'S') {
+ return nxt.prefix == 'O' || nxt.prefix == 'B' || nxt.prefix == 'S';
+ }
+ if (prev.prefix == 'B' || prev.prefix == 'I') {
+ return (nxt.prefix == 'I' || nxt.prefix == 'E') &&
+ nxt.entity == prev.entity;
+ }
+ return false;
+}
+
+float biasFor(const LabelRole &prev, const LabelRole &nxt, const Biases &b) {
+ if (prev.prefix == 'O' && nxt.prefix == 'O') {
+ return b.backgroundStay;
+ }
+ if (prev.prefix == 'O' && (nxt.prefix == 'B' || nxt.prefix == 'S')) {
+ return b.backgroundToStart;
+ }
+ if ((prev.prefix == 'E' || prev.prefix == 'S') && nxt.prefix == 'O') {
+ return b.endToBackground;
+ }
+ if ((prev.prefix == 'E' || prev.prefix == 'S') &&
+ (nxt.prefix == 'B' || nxt.prefix == 'S')) {
+ return b.endToStart;
+ }
+ if ((prev.prefix == 'B' || prev.prefix == 'I') && nxt.prefix == 'I') {
+ return b.insideToContinue;
+ }
+ if ((prev.prefix == 'B' || prev.prefix == 'I') && nxt.prefix == 'E') {
+ return b.insideToEnd;
+ }
+ return 0.0f;
+}
+
+} // namespace
+
+Grammar buildGrammar(const std::vector &labelNames,
+ const Biases &biases) {
+ const size_t N = labelNames.size();
+ std::vector roles;
+ roles.reserve(N);
+ for (const auto &name : labelNames) {
+ roles.emplace_back(classifyLabel(name));
+ }
+
+ Grammar grammar;
+ grammar.numLabels = N;
+ // transitionScore[i*N+j]: bias for valid transitions, -inf for invalid.
+ // Fused float lets the inner loop avoid a validity branch.
+ grammar.transitionScore.assign(N * N, kNegInf);
+ for (size_t i = 0; i < N; ++i) {
+ for (size_t j = 0; j < N; ++j) {
+ if (isValidTransition(roles[i], roles[j])) {
+ grammar.transitionScore[i * N + j] =
+ biasFor(roles[i], roles[j], biases);
+ }
+ }
+ }
+
+ grammar.validStart.assign(N, false);
+ for (size_t i = 0; i < N; ++i) {
+ grammar.validStart[i] = (roles[i].prefix == 'O' || roles[i].prefix == 'B' ||
+ roles[i].prefix == 'S');
+ }
+ return grammar;
+}
+
+std::vector decode(const float *logits, int32_t validLen,
+ const Grammar &grammar) {
+ if (validLen <= 0) {
+ return {};
+ }
+
+ const size_t N = grammar.numLabels;
+ std::vector dp(N, kNegInf);
+ std::vector dpNext(N, kNegInf);
+ // Allocate bp as [validLen, N] for indexing convenience; row 0 is unused
+ // (traceback starts at t = 1 and consults bp[t * N + j]).
+ std::vector bp(static_cast(validLen) * N, 0);
+
+ {
+ const float *row0 = logits;
+ for (size_t j = 0; j < N; ++j) {
+ dp[j] = grammar.validStart[j] ? row0[j] : kNegInf;
+ }
+ }
+
+ for (int32_t t = 1; t < validLen; ++t) {
+ const float *rowT = logits + static_cast(t) * N;
+ for (size_t j = 0; j < N; ++j) {
+ float best = kNegInf;
+ int32_t bestPrev = 0;
+ for (size_t i = 0; i < N; ++i) {
+ const float trans = grammar.transitionScore[i * N + j];
+ if (trans <= kNegInf / 2.0f) {
+ continue;
+ }
+ const float cand = dp[i] + trans;
+ if (cand > best) {
+ best = cand;
+ bestPrev = static_cast(i);
+ }
+ }
+ dpNext[j] = best == kNegInf ? kNegInf : best + rowT[j];
+ bp[static_cast(t) * N + j] = bestPrev;
+ }
+ std::swap(dp, dpNext);
+ }
+
+ auto it = std::ranges::max_element(dp);
+ size_t bestEnd = std::distance(dp.begin(), it);
+
+ std::vector path(static_cast(validLen), 0);
+ path[static_cast(validLen) - 1] = static_cast(bestEnd);
+ for (int32_t t = validLen - 1; t > 0; --t) {
+ path[static_cast(t) - 1] =
+ bp[static_cast(t) * N +
+ static_cast(path[static_cast(t)])];
+ }
+ return path;
+}
+
+} // namespace rnexecutorch::models::privacy_filter::viterbi
diff --git a/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.h b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.h
new file mode 100644
index 0000000000..ec0912c7fb
--- /dev/null
+++ b/packages/react-native-executorch/common/rnexecutorch/models/privacy_filter/Viterbi.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace rnexecutorch::models::privacy_filter::viterbi {
+
+// Six Viterbi transition biases matching the openai/privacy-filter
+// viterbi_calibration.json schema. Each value is added to the decoder score
+// for the corresponding BIOES transition. Positive = encourage, negative =
+// discourage. Defaults are zero (neutral, validity-only Viterbi).
+// JSI callers pass a 6-element vector; indices match the field order below.
+struct Biases {
+ float backgroundStay = 0.0f; // [0] O -> O
+ float backgroundToStart = 0.0f; // [1] O -> B-* / S-*
+ float endToBackground = 0.0f; // [2] E-/S-* -> O
+ float endToStart = 0.0f; // [3] E-/S-* -> B-* / S-*
+ float insideToContinue = 0.0f; // [4] B-/I-X -> I-X
+ float insideToEnd = 0.0f; // [5] B-/I-X -> E-X
+};
+
+// Pre-computed BIOES grammar tables.
+// transitionScore[i*N + j] = bias for valid transitions, -inf for invalid.
+// validStart[i] = true iff label i is a legal first-token label (O, B-*,
+// S-*).
+struct Grammar {
+ std::vector transitionScore;
+ std::vector validStart;
+ size_t numLabels = 0;
+};
+
+Grammar buildGrammar(const std::vector &labelNames,
+ const Biases &biases);
+
+// Run constrained Viterbi over [validLen, numLabels] logits and return the
+// best BIOES-grammar-valid label-id sequence (length validLen).
+std::vector decode(const float *logits, int32_t validLen,
+ const Grammar &grammar);
+
+} // namespace rnexecutorch::models::privacy_filter::viterbi
diff --git a/packages/react-native-executorch/src/constants/modelUrls.ts b/packages/react-native-executorch/src/constants/modelUrls.ts
index 331b8eba1e..432f915eef 100644
--- a/packages/react-native-executorch/src/constants/modelUrls.ts
+++ b/packages/react-native-executorch/src/constants/modelUrls.ts
@@ -1,4 +1,8 @@
import { Platform } from 'react-native';
+import {
+ PRIVACY_FILTER_NEMOTRON_LABELS,
+ PRIVACY_FILTER_OPENAI_LABELS,
+} from './privacyFilterLabels';
import { URL_PREFIX, VERSION_TAG, NEXT_VERSION_TAG } from './versions';
// LLMs
@@ -1153,6 +1157,39 @@ export const CLIP_VIT_BASE_PATCH32_TEXT = {
tokenizerSource: CLIP_VIT_BASE_PATCH32_TEXT_TOKENIZER,
} as const;
+// Privacy Filter (PII detection)
+//
+// Both supported variants share the same architecture (8-layer MoE,
+// 128-token banded attention) and tokenizer (o200k, pad/eos id 199999).
+// They differ only in the BIOES label space; the runner expects the
+// matching `labelNames` array to be passed at load time.
+
+/**
+ * openai/privacy-filter — base PII detector with 8 entity types
+ * (account_number, private_address, private_date, private_email,
+ * private_person, private_phone, private_url, secret).
+ * @category Models - Privacy Filter
+ */
+export const PRIVACY_FILTER_OPENAI = {
+ modelName: 'privacy-filter-openai',
+ modelSource: `${URL_PREFIX}-privacy-filter-openai/${NEXT_VERSION_TAG}/xnnpack/privacy-filter_xnnpack_8da4w.pte`,
+ tokenizerSource: `${URL_PREFIX}-privacy-filter-openai/${NEXT_VERSION_TAG}/tokenizer.json`,
+ labelNames: PRIVACY_FILTER_OPENAI_LABELS,
+} as const;
+
+/**
+ * OpenMed/privacy-filter-nemotron — extended PII detector with 55 entity
+ * types (adds medical, financial, identity, technical, demographic, etc.).
+ * Same base architecture as the OpenAI model, larger label space.
+ * @category Models - Privacy Filter
+ */
+export const PRIVACY_FILTER_NEMOTRON = {
+ modelName: 'privacy-filter-nemotron',
+ modelSource: `${URL_PREFIX}-privacy-filter-nemotron/${NEXT_VERSION_TAG}/xnnpack/privacy-filter-nemotron_xnnpack_8da4w.pte`,
+ tokenizerSource: `${URL_PREFIX}-privacy-filter-nemotron/${NEXT_VERSION_TAG}/tokenizer.json`,
+ labelNames: PRIVACY_FILTER_NEMOTRON_LABELS,
+} as const;
+
// Image generation
/**
@@ -1305,6 +1342,8 @@ export const MODEL_REGISTRY = {
BK_SDM_TINY_VPRED_512,
BK_SDM_TINY_VPRED_256,
FSMN_VAD,
+ PRIVACY_FILTER_OPENAI,
+ PRIVACY_FILTER_NEMOTRON,
},
} as const;
diff --git a/packages/react-native-executorch/src/constants/privacyFilterLabels.ts b/packages/react-native-executorch/src/constants/privacyFilterLabels.ts
new file mode 100644
index 0000000000..e5a63d69ad
--- /dev/null
+++ b/packages/react-native-executorch/src/constants/privacyFilterLabels.ts
@@ -0,0 +1,272 @@
+// BIOES tag scheme: 1 outside ("O") + 4 prefix variants × N entity types.
+// These arrays must match the model's id2label mapping exactly — the runner
+// uses index = label id, and labels[0] must be "O".
+
+/**
+ * Label space for the openai/privacy-filter base model (8 entity types,
+ * 33 labels).
+ */
+export const PRIVACY_FILTER_OPENAI_LABELS = [
+ 'O',
+ 'B-account_number',
+ 'I-account_number',
+ 'E-account_number',
+ 'S-account_number',
+ 'B-private_address',
+ 'I-private_address',
+ 'E-private_address',
+ 'S-private_address',
+ 'B-private_date',
+ 'I-private_date',
+ 'E-private_date',
+ 'S-private_date',
+ 'B-private_email',
+ 'I-private_email',
+ 'E-private_email',
+ 'S-private_email',
+ 'B-private_person',
+ 'I-private_person',
+ 'E-private_person',
+ 'S-private_person',
+ 'B-private_phone',
+ 'I-private_phone',
+ 'E-private_phone',
+ 'S-private_phone',
+ 'B-private_url',
+ 'I-private_url',
+ 'E-private_url',
+ 'S-private_url',
+ 'B-secret',
+ 'I-secret',
+ 'E-secret',
+ 'S-secret',
+] as const;
+
+/**
+ * Label space for the OpenMed/privacy-filter-nemotron model (55 entity
+ * types, 221 labels). Source:
+ * https://huggingface.co/OpenMed/privacy-filter-nemotron/blob/main/config.json
+ */
+export const PRIVACY_FILTER_NEMOTRON_LABELS = [
+ 'O',
+ 'B-account_number',
+ 'I-account_number',
+ 'E-account_number',
+ 'S-account_number',
+ 'B-age',
+ 'I-age',
+ 'E-age',
+ 'S-age',
+ 'B-api_key',
+ 'I-api_key',
+ 'E-api_key',
+ 'S-api_key',
+ 'B-bank_routing_number',
+ 'I-bank_routing_number',
+ 'E-bank_routing_number',
+ 'S-bank_routing_number',
+ 'B-biometric_identifier',
+ 'I-biometric_identifier',
+ 'E-biometric_identifier',
+ 'S-biometric_identifier',
+ 'B-blood_type',
+ 'I-blood_type',
+ 'E-blood_type',
+ 'S-blood_type',
+ 'B-certificate_license_number',
+ 'I-certificate_license_number',
+ 'E-certificate_license_number',
+ 'S-certificate_license_number',
+ 'B-city',
+ 'I-city',
+ 'E-city',
+ 'S-city',
+ 'B-company_name',
+ 'I-company_name',
+ 'E-company_name',
+ 'S-company_name',
+ 'B-coordinate',
+ 'I-coordinate',
+ 'E-coordinate',
+ 'S-coordinate',
+ 'B-country',
+ 'I-country',
+ 'E-country',
+ 'S-country',
+ 'B-county',
+ 'I-county',
+ 'E-county',
+ 'S-county',
+ 'B-credit_debit_card',
+ 'I-credit_debit_card',
+ 'E-credit_debit_card',
+ 'S-credit_debit_card',
+ 'B-customer_id',
+ 'I-customer_id',
+ 'E-customer_id',
+ 'S-customer_id',
+ 'B-cvv',
+ 'I-cvv',
+ 'E-cvv',
+ 'S-cvv',
+ 'B-date',
+ 'I-date',
+ 'E-date',
+ 'S-date',
+ 'B-date_of_birth',
+ 'I-date_of_birth',
+ 'E-date_of_birth',
+ 'S-date_of_birth',
+ 'B-date_time',
+ 'I-date_time',
+ 'E-date_time',
+ 'S-date_time',
+ 'B-device_identifier',
+ 'I-device_identifier',
+ 'E-device_identifier',
+ 'S-device_identifier',
+ 'B-education_level',
+ 'I-education_level',
+ 'E-education_level',
+ 'S-education_level',
+ 'B-email',
+ 'I-email',
+ 'E-email',
+ 'S-email',
+ 'B-employee_id',
+ 'I-employee_id',
+ 'E-employee_id',
+ 'S-employee_id',
+ 'B-employment_status',
+ 'I-employment_status',
+ 'E-employment_status',
+ 'S-employment_status',
+ 'B-fax_number',
+ 'I-fax_number',
+ 'E-fax_number',
+ 'S-fax_number',
+ 'B-first_name',
+ 'I-first_name',
+ 'E-first_name',
+ 'S-first_name',
+ 'B-gender',
+ 'I-gender',
+ 'E-gender',
+ 'S-gender',
+ 'B-health_plan_beneficiary_number',
+ 'I-health_plan_beneficiary_number',
+ 'E-health_plan_beneficiary_number',
+ 'S-health_plan_beneficiary_number',
+ 'B-http_cookie',
+ 'I-http_cookie',
+ 'E-http_cookie',
+ 'S-http_cookie',
+ 'B-ipv4',
+ 'I-ipv4',
+ 'E-ipv4',
+ 'S-ipv4',
+ 'B-ipv6',
+ 'I-ipv6',
+ 'E-ipv6',
+ 'S-ipv6',
+ 'B-language',
+ 'I-language',
+ 'E-language',
+ 'S-language',
+ 'B-last_name',
+ 'I-last_name',
+ 'E-last_name',
+ 'S-last_name',
+ 'B-license_plate',
+ 'I-license_plate',
+ 'E-license_plate',
+ 'S-license_plate',
+ 'B-mac_address',
+ 'I-mac_address',
+ 'E-mac_address',
+ 'S-mac_address',
+ 'B-medical_record_number',
+ 'I-medical_record_number',
+ 'E-medical_record_number',
+ 'S-medical_record_number',
+ 'B-national_id',
+ 'I-national_id',
+ 'E-national_id',
+ 'S-national_id',
+ 'B-occupation',
+ 'I-occupation',
+ 'E-occupation',
+ 'S-occupation',
+ 'B-password',
+ 'I-password',
+ 'E-password',
+ 'S-password',
+ 'B-phone_number',
+ 'I-phone_number',
+ 'E-phone_number',
+ 'S-phone_number',
+ 'B-pin',
+ 'I-pin',
+ 'E-pin',
+ 'S-pin',
+ 'B-political_view',
+ 'I-political_view',
+ 'E-political_view',
+ 'S-political_view',
+ 'B-postcode',
+ 'I-postcode',
+ 'E-postcode',
+ 'S-postcode',
+ 'B-race_ethnicity',
+ 'I-race_ethnicity',
+ 'E-race_ethnicity',
+ 'S-race_ethnicity',
+ 'B-religious_belief',
+ 'I-religious_belief',
+ 'E-religious_belief',
+ 'S-religious_belief',
+ 'B-sexuality',
+ 'I-sexuality',
+ 'E-sexuality',
+ 'S-sexuality',
+ 'B-ssn',
+ 'I-ssn',
+ 'E-ssn',
+ 'S-ssn',
+ 'B-state',
+ 'I-state',
+ 'E-state',
+ 'S-state',
+ 'B-street_address',
+ 'I-street_address',
+ 'E-street_address',
+ 'S-street_address',
+ 'B-swift_bic',
+ 'I-swift_bic',
+ 'E-swift_bic',
+ 'S-swift_bic',
+ 'B-tax_id',
+ 'I-tax_id',
+ 'E-tax_id',
+ 'S-tax_id',
+ 'B-time',
+ 'I-time',
+ 'E-time',
+ 'S-time',
+ 'B-unique_id',
+ 'I-unique_id',
+ 'E-unique_id',
+ 'S-unique_id',
+ 'B-url',
+ 'I-url',
+ 'E-url',
+ 'S-url',
+ 'B-user_name',
+ 'I-user_name',
+ 'E-user_name',
+ 'S-user_name',
+ 'B-vehicle_identifier',
+ 'I-vehicle_identifier',
+ 'E-vehicle_identifier',
+ 'S-vehicle_identifier',
+] as const;
diff --git a/packages/react-native-executorch/src/hooks/natural_language_processing/usePrivacyFilter.ts b/packages/react-native-executorch/src/hooks/natural_language_processing/usePrivacyFilter.ts
new file mode 100644
index 0000000000..aebb76b21d
--- /dev/null
+++ b/packages/react-native-executorch/src/hooks/natural_language_processing/usePrivacyFilter.ts
@@ -0,0 +1,32 @@
+import { PrivacyFilterModule } from '../../modules/natural_language_processing/PrivacyFilterModule';
+import { useModuleFactory } from '../useModuleFactory';
+import {
+ PiiEntity,
+ PrivacyFilterProps,
+ PrivacyFilterType,
+} from '../../types/privacyFilter';
+
+/**
+ * React hook for managing a Privacy Filter model instance.
+ * @category Hooks
+ * @param PrivacyFilterProps - Configuration object containing the model sources and an optional `preventLoad` flag.
+ * @returns Ready to use Privacy Filter model.
+ */
+export const usePrivacyFilter = ({
+ model,
+ preventLoad = false,
+}: PrivacyFilterProps): PrivacyFilterType => {
+ const { error, isReady, isGenerating, downloadProgress, runForward } =
+ useModuleFactory({
+ factory: (config, onProgress) =>
+ PrivacyFilterModule.fromModelName(config, onProgress),
+ config: model,
+ deps: [model.modelName, model.modelSource, model.tokenizerSource],
+ preventLoad,
+ });
+
+ const generate = (text: string): Promise =>
+ runForward((inst) => inst.generate(text));
+
+ return { error, isReady, isGenerating, downloadProgress, generate };
+};
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index 296c94b6e8..7cc148d16b 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -71,6 +71,12 @@ declare global {
tokenizerSource: string,
capabilities: readonly LLMCapability[]
) => Promise;
+ var loadPrivacyFilter: (
+ modelSource: string,
+ tokenizerSource: string,
+ labelNames: readonly string[],
+ viterbiBiases: readonly number[]
+ ) => Promise;
var loadTextToImage: (
tokenizerSource: string,
encoderSource: string,
@@ -123,6 +129,7 @@ if (
global.loadImageEmbeddings == null ||
global.loadVAD == null ||
global.loadLLM == null ||
+ global.loadPrivacyFilter == null ||
global.loadSpeechToText == null ||
global.loadTextToSpeechKokoro == null ||
global.loadOCR == null ||
@@ -162,6 +169,7 @@ export * from './hooks/computer_vision/useTextToImage';
export * from './hooks/natural_language_processing/useLLM';
export * from './hooks/natural_language_processing/useSpeechToText';
export * from './hooks/natural_language_processing/useTextToSpeech';
+export * from './hooks/natural_language_processing/usePrivacyFilter';
export * from './hooks/natural_language_processing/useTextEmbeddings';
export * from './hooks/natural_language_processing/useTokenizer';
export * from './hooks/natural_language_processing/useVAD';
@@ -182,6 +190,7 @@ export * from './modules/computer_vision/TextToImageModule';
export * from './modules/natural_language_processing/LLMModule';
export * from './modules/natural_language_processing/SpeechToTextModule';
export * from './modules/natural_language_processing/TextToSpeechModule';
+export * from './modules/natural_language_processing/PrivacyFilterModule';
export * from './modules/natural_language_processing/TextEmbeddingsModule';
export * from './modules/natural_language_processing/TokenizerModule';
export * from './modules/natural_language_processing/VADModule';
@@ -206,6 +215,7 @@ export * from './types/vad';
export * from './types/common';
export * from './types/stt';
export * from './types/textEmbeddings';
+export * from './types/privacyFilter';
export * from './types/tts';
export * from './types/tokenizer';
export * from './types/executorchModule';
diff --git a/packages/react-native-executorch/src/modules/natural_language_processing/PrivacyFilterModule.ts b/packages/react-native-executorch/src/modules/natural_language_processing/PrivacyFilterModule.ts
new file mode 100644
index 0000000000..d32a1e85fb
--- /dev/null
+++ b/packages/react-native-executorch/src/modules/natural_language_processing/PrivacyFilterModule.ts
@@ -0,0 +1,126 @@
+import { ResourceSource } from '../../types/common';
+import {
+ PiiEntity,
+ PrivacyFilterModelSources,
+ ViterbiBiases,
+} from '../../types/privacyFilter';
+import { ResourceFetcher } from '../../utils/ResourceFetcher';
+import { BaseModule } from '../BaseModule';
+import { RnExecutorchErrorCode } from '../../errors/ErrorCodes';
+import { parseUnknownError, RnExecutorchError } from '../../errors/errorUtils';
+import { Logger } from '../../common/Logger';
+
+/**
+ * Pack the optional ViterbiBiases struct into a 6-element array in the
+ * fixed order the native side expects. Missing fields default to 0.
+ * @param biases - Caller-supplied biases (any subset of the 6 fields).
+ * @returns A 6-element number[] in the canonical bias order.
+ */
+function packViterbiBiases(biases?: ViterbiBiases): number[] {
+ return [
+ biases?.backgroundStay ?? 0,
+ biases?.backgroundToStart ?? 0,
+ biases?.endToBackground ?? 0,
+ biases?.endToStart ?? 0,
+ biases?.insideToContinue ?? 0,
+ biases?.insideToEnd ?? 0,
+ ];
+}
+
+/**
+ * Module for running token-level PII detection over text. Supports any
+ * privacy-filter-style model with a `forward(input_ids, attention_mask)`
+ * graph and a BIOES label space (the runner reads `labelNames` to map
+ * predicted indices back to entity types).
+ * @category Typescript API
+ */
+export class PrivacyFilterModule extends BaseModule {
+ private constructor(nativeModule: unknown) {
+ super();
+ this.nativeModule = nativeModule;
+ }
+
+ /**
+ * Creates a Privacy Filter instance for a built-in or custom-shaped model.
+ * Pass one of the `PRIVACY_FILTER_*` constants from
+ * `react-native-executorch/constants` for a known-good config, or
+ * construct your own {@link PrivacyFilterModelSources} for a custom
+ * fine-tune.
+ * @param namedSources - Model + tokenizer resource locations and label list.
+ * @param onDownloadProgress - Optional 0..1 download progress callback.
+ * @returns A Promise resolving to a `PrivacyFilterModule` instance.
+ */
+ static async fromModelName(
+ namedSources: PrivacyFilterModelSources,
+ onDownloadProgress: (progress: number) => void = () => {}
+ ): Promise {
+ try {
+ const [modelResult, tokenizerResult] = await Promise.all([
+ ResourceFetcher.fetch(onDownloadProgress, namedSources.modelSource),
+ ResourceFetcher.fetch(undefined, namedSources.tokenizerSource),
+ ]);
+ const modelPath = modelResult?.[0];
+ const tokenizerPath = tokenizerResult?.[0];
+ if (!modelPath || !tokenizerPath) {
+ throw new RnExecutorchError(
+ RnExecutorchErrorCode.DownloadInterrupted,
+ 'The download has been interrupted. As a result, not every file was downloaded. Please retry the download.'
+ );
+ }
+ const labels = Array.from(namedSources.labelNames);
+ const biases = packViterbiBiases(namedSources.viterbiBiases);
+ return new PrivacyFilterModule(
+ await global.loadPrivacyFilter(modelPath, tokenizerPath, labels, biases)
+ );
+ } catch (error) {
+ Logger.error('Load failed:', error);
+ throw parseUnknownError(error);
+ }
+ }
+
+ /**
+ * Creates a Privacy Filter instance with a user-provided model binary and tokenizer.
+ * Use this when working with a custom-exported model that is not one of the built-in presets.
+ * @remarks The `labelNames` array must match the model's head dimension and id2label mapping exactly.
+ * @param modelSource - A fetchable resource pointing to the .pte file.
+ * @param tokenizerSource - A fetchable resource pointing to the tokenizer.json.
+ * @param labelNames - BIOES label list; index 0 must be "O".
+ * @param options - Optional Viterbi biases and download progress callback.
+ * @returns A Promise resolving to a `PrivacyFilterModule` instance.
+ */
+ static fromCustomModel(
+ modelSource: ResourceSource,
+ tokenizerSource: ResourceSource,
+ labelNames: readonly string[],
+ options: {
+ viterbiBiases?: ViterbiBiases;
+ onDownloadProgress?: (progress: number) => void;
+ } = {}
+ ): Promise {
+ return PrivacyFilterModule.fromModelName(
+ {
+ modelName: 'custom',
+ modelSource,
+ tokenizerSource,
+ labelNames,
+ viterbiBiases: options.viterbiBiases,
+ },
+ options.onDownloadProgress ?? (() => {})
+ );
+ }
+
+ /**
+ * Executes the model's forward pass to detect PII entity spans within the provided text.
+ * @param text - The input text to scan for PII.
+ * @returns A Promise resolving to an array of detected {@link PiiEntity} spans.
+ */
+ async generate(text: string): Promise {
+ if (this.nativeModule == null) {
+ throw new RnExecutorchError(
+ RnExecutorchErrorCode.ModuleNotLoaded,
+ 'The model is currently not loaded. Please load the model before calling generate().'
+ );
+ }
+ return (await this.nativeModule.generate(text)) as PiiEntity[];
+ }
+}
diff --git a/packages/react-native-executorch/src/types/privacyFilter.ts b/packages/react-native-executorch/src/types/privacyFilter.ts
new file mode 100644
index 0000000000..9dc6ad6144
--- /dev/null
+++ b/packages/react-native-executorch/src/types/privacyFilter.ts
@@ -0,0 +1,104 @@
+import { RnExecutorchError } from '../errors/errorUtils';
+import { ResourceSource } from './common';
+
+/**
+ * Union of all built-in privacy filter model names.
+ * @category Types
+ */
+export type PrivacyFilterModelName =
+ | 'privacy-filter-openai'
+ | 'privacy-filter-nemotron';
+
+/**
+ * Six Viterbi transition biases that match the operating-point schema
+ * from the openai/privacy-filter `viterbi_calibration.json`. Each value
+ * is added to the decoder score whenever the corresponding BIOES
+ * transition is taken.
+ *
+ * Positive values *encourage* the transition; negative values discourage
+ * it. Defaults are zero (neutral validity-only Viterbi).
+ * @category Types
+ */
+export interface ViterbiBiases {
+ /** O -> O (background persistence). Higher = stay in background more, fewer false positives. */
+ backgroundStay?: number;
+ /** O -> B-* / S-* (span entry). Lower (negative) = enter spans more eagerly, higher recall. */
+ backgroundToStart?: number;
+ /** E-/S-* -> O (span closure to background). */
+ endToBackground?: number;
+ /** E-/S-* -> B-* / S-* (back-to-back spans). */
+ endToStart?: number;
+ /** B-/I-X -> I-X (span continuation). Higher = longer spans. */
+ insideToContinue?: number;
+ /** B-/I-X -> E-X (span closure). Higher = shorter spans. */
+ insideToEnd?: number;
+}
+
+/**
+ * Bundle of resources needed to instantiate a privacy filter model. The
+ * built-in `PRIVACY_FILTER_OPENAI` / `PRIVACY_FILTER_NEMOTRON` constants
+ * conform to this shape; you can also build one yourself for a custom
+ * fine-tune as long as the label list matches the model's id2label.
+ * @category Types
+ */
+export interface PrivacyFilterModelSources {
+ modelName: PrivacyFilterModelName | (string & {});
+ modelSource: ResourceSource;
+ tokenizerSource: ResourceSource;
+ /**
+ * BIOES label list. Index 0 must be "O"; index i must equal the model's
+ * id2label[i]. The runner argmaxes over `labelNames.length` classes per
+ * token, so the size must match the model head exactly.
+ */
+ labelNames: readonly string[];
+ /**
+ * Optional Viterbi calibration. When present, biases are added during
+ * decoding to shift the precision/recall tradeoff. Defaults to all
+ * zeros (neutral) — same as the `default` operating point in OpenAI's
+ * `viterbi_calibration.json`.
+ */
+ viterbiBiases?: ViterbiBiases;
+}
+
+/**
+ * A single detected PII entity span.
+ * @category Types
+ */
+export interface PiiEntity {
+ /** Entity type, e.g. `private_person`, `private_email`, `secret`. */
+ label: string;
+ /** Decoded text of the span (whitespace trimmed). */
+ text: string;
+ /** Inclusive start token index in the original (unpadded) tokenization. */
+ startToken: number;
+ /** Exclusive end token index. */
+ endToken: number;
+}
+
+/**
+ * Props for the usePrivacyFilter hook.
+ * @category Types
+ */
+export interface PrivacyFilterProps {
+ model: PrivacyFilterModelSources;
+ preventLoad?: boolean;
+}
+
+/**
+ * React hook state and methods for a Privacy Filter model.
+ * @category Types
+ */
+export interface PrivacyFilterType {
+ error: null | RnExecutorchError;
+ isReady: boolean;
+ isGenerating: boolean;
+ downloadProgress: number;
+ /**
+ * Run PII detection over the given text. Long inputs are processed in
+ * sliding windows with 50% overlap; no truncation. The window size is
+ * determined by the model's exported `forward` input shape.
+ * @param text Input text.
+ * @returns A promise resolving to detected entity spans.
+ */
+ generate(text: string): Promise;
+}