<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Vishal Ojha]]></title><description><![CDATA[Technical articles by Vishal Ojha on software engineering, AI/ML, web development, system design, databases, and building practical developer projects.]]></description><link>https://vishalkumarojha.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/66fc3f0d2d6a5ab57529b5fe/81edbb83-b392-49ad-ab92-f7a200263d8b.png</url><title>Vishal Ojha</title><link>https://vishalkumarojha.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 06:37:41 GMT</lastBuildDate><atom:link href="https://vishalkumarojha.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[From Pixels to Narration: Engineering a Multimodal Accessibility System for the Visually Impaired]]></title><description><![CDATA[Explore how ArnoX combines OCR, image labeling, video narration concepts, and text-to-speech to make visual information more accessible.]]></description><link>https://vishalkumarojha.hashnode.dev/from-pixels-to-narration-engineering-a-multimodal-accessibility-system-for-the-visually-impaired</link><guid isPermaLink="true">https://vishalkumarojha.hashnode.dev/from-pixels-to-narration-engineering-a-multimodal-accessibility-system-for-the-visually-impaired</guid><category><![CDATA[Accessibility]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Flutter]]></category><dc:creator><![CDATA[Vishal Ojha]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:46:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66fc3f0d2d6a5ab57529b5fe/74672f2f-a8ed-4125-aecd-92fef6cfc608.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Visual information is everywhere, but it is not equally available to everyone. A medicine label, a bus timetable, a restaurant menu, a lecture slide, or a short video can carry important context that is difficult to access without sight. Braille, screen readers, and mobility aids help, but they do not automatically solve the problem of understanding an arbitrary image or an unlabeled video.</p>
<p>This article examines <strong>ArnoX</strong>, a Flutter accessibility app, and the IEEE research paper behind it: <em>“Multimodal Narration System for Enhancing Accessibility for the Visually Impaired”</em> by Swagat Kumar Samantaray, Mansi Kaushik, Vishal Kumar Ojha, Rishav Mishra, and Abhishek Kumar, published at the 2025 International Conference on Cognitive, Green and Ubiquitous Computing (IC-CGU). The full paper is on IEEE Xplore: <a href="https://ieeexplore.ieee.org/document/11337992">https://ieeexplore.ieee.org/document/11337992</a>.</p>
<p>The goal is not to replace the paper, but to explain its engineering context: why OCR alone is not enough, how image and video narration differ, what the research architecture proposes, what the repository actually implements, and where the two diverge.</p>
<h2>The information hidden in everyday visuals</h2>
<p>Consider a reader who cannot see the packaging of a new medication. The box may contain the drug name, dosage, expiry date, and warnings. A screen reader can read that text if it already exists in an accessible digital format. It cannot read a photograph of the box unless something first extracts the text from the image.</p>
<p>Now consider a photo of a room or a street scene. There may be little text, but the visual content still matters: what objects are present, how they relate, whether a path is clear, or which way a sign points. OCR has nothing to extract from a scene with no readable text. A description is needed instead.</p>
<p>Video adds a third layer. A single frame might show a person, a door, and a chair. Across time, the same sequence might show someone walking toward the door and leaving. Understanding that change requires temporal information, not just one still image.</p>
<p>These are different accessibility problems that need different kinds of machine understanding. That is the central idea behind multimodal narration: combine text recognition, visual description, and speech so the user receives more than one narrow view of the same input.</p>
<p>The paper frames the problem similarly. It notes that visual information is fundamental to daily interaction, while visually impaired people face barriers in accessing digital and printed media. It argues that Braille, screen readers, and mobility aids often lack real-time adaptability, multimodal interaction, and contextual understanding. The paper cites a 2011 World Health Organization figure of 285 million people with visual impairment, including 39 million blind and 246 million with mild or severe impairment. That is a historical reference point; current WHO estimates use a broader definition and a much larger number, so the statistic should be read in context.</p>
<p>The precise number matters less than the dependency: visual information is part of daily life, and existing interaction patterns leave gaps.</p>
<h2>The problem and the research motivation</h2>
<p>The paper’s problem statement is straightforward: assistive tools tend to operate in isolation. OCR can extract text, TTS can read text aloud, and image captioning can describe a scene, but a user often has to move between separate tools or accept a partial result. A screen reader does not necessarily describe an image. A captioning demo does not necessarily read the text printed inside that image. A video description tool may not expose its result through an accessible mobile interface.</p>
<p>The research proposes an integrated pipeline combining:</p>
<ul>
<li><strong>Optical Character Recognition (OCR)</strong> to extract visible text.</li>
<li><strong>Image captioning</strong> to describe visual content.</li>
<li><strong>Video captioning</strong> to describe visual content over time.</li>
<li><strong>Text-to-Speech (TTS)</strong> to deliver the result as audio.</li>
</ul>
<p>The paper’s stated objective is a real-time, AI-powered multimodal narration application. Its three-layer design is:</p>
<ol>
<li><strong>Input layer:</strong> capture an image through a smartphone camera or upload one.</li>
<li><strong>Processing layer:</strong> run OCR and image captioning.</li>
<li><strong>Output layer:</strong> deliver synthesized speech through TTS.</li>
</ol>
<p>That is the research contribution in broad strokes. The repository tells a more specific and more complicated story.</p>
<h2>Why multimodal accessibility matters</h2>
<p>It is tempting to treat OCR as “the accessibility feature.” That is too narrow.</p>
<p>OCR converts pixels that look like text into machine-readable characters. It works well for documents, labels, signs, and screenshots. It is weak when an image is blurry, rotated, poorly lit, handwritten, or full of decorative fonts. It also returns text without necessarily preserving layout, so a receipt, table, or multi-column page can become a stream of words that is technically correct but hard to interpret.</p>
<p>Image captioning takes a different path. A captioning model looks at the whole image and generates a natural-language description: “a blue cup on a wooden table,” or “a person standing beside a bicycle.” This is useful when there is little or no text. But captions can miss small text, hallucinate details, or blur the distinction between what is certain and what is guessed.</p>
<p>Video captioning adds time. Meaning often comes from motion, order, and change. Frame-by-frame descriptions can help, but they can also produce repetitive narration if the system does not aggregate or reason over time. The paper treats video as a frame-wise analysis problem with scene-level descriptions from an external API. That is a practical starting point, not a complete theory of video understanding.</p>
<p>TTS is the delivery layer. It turns the combined result into audio so the user can listen instead of reading a screen. Voice quality, speaking rate, language, and the ability to stop or repeat playback all affect whether the output is genuinely usable.</p>
<p>The modalities complement one another:</p>
<ul>
<li>OCR answers: <em>What text is here?</em></li>
<li>Image captioning answers: <em>What is shown here?</em></li>
<li>Video captioning answers: <em>What is happening over time?</em></li>
<li>TTS answers: <em>How can this be delivered without requiring sight?</em></li>
</ul>
<p>A system that uses one of these can be helpful. A system that combines them covers more of the information a sighted user takes for granted.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW1Zpc3VhbCBpbnB1dF0gLS0-IEJ7SW5wdXQgbW9kYWxpdHl9CiAgICBCIC0tPnxJbWFnZXwgQ1tPQ1IgYnJhbmNoXQogICAgQiAtLT58SW1hZ2V8IERbQ2FwdGlvbiBicmFuY2hdCiAgICBCIC0tPnxWaWRlb3wgRVtWaWRlbyBicmFuY2hdCiAgICBzdWJncmFwaCBNW011bHRpbW9kYWwgdW5kZXJzdGFuZGluZ10KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBDIC0tPiBGW1Zpc2libGUgdGV4dF0KICAgICAgICBEIC0tPiBHW1NjZW5lIGRlc2NyaXB0aW9uXQogICAgICAgIEUgLS0-IEhbVGVtcG9yYWwgY2FwdGlvbnNdCiAgICBlbmQKICAgIEYgLS0-IElbQ29udGVudCBmdXNpb25dCiAgICBHIC0tPiBJCiAgICBIIC0tPiBJCiAgICBJIC0tPiBKW1N0cnVjdHVyZWQgbmFycmF0aW9uXQogICAgSiAtLT4gS1tUZXh0LXRvLXNwZWVjaF0KICAgIEogLS0-IExbVmlzdWFsIGRpc3BsYXldCiAgICBLIC0tPiBOW0F1ZGlvIG91dHB1dF0KICAgIEwgLS0-IE4K?type=png&amp;bgColor=white&amp;width=800" alt="Multimodal accessibility pipeline showing OCR, image captioning, video captioning, content fusion, text-to-speech, and accessible audio output" /></p>
<p><em>Figure 1. A conceptual multimodal pipeline. OCR handles visible text, captioning handles visual content, video adds temporal descriptions, and TTS delivers the result. Redrawn from the paper’s system description.</em></p>
<h2>Introducing the research system</h2>
<p>The IEEE paper describes a Flutter front end connected to a backend ensemble. For images, the app sends input to a Flask API, where OpenCV handles preprocessing and text detection, and Tesseract performs OCR. The extracted content is structured as JSON and stored in Firebase. For video, the paper says input is processed through the Google Studio API, which performs frame-by-frame analysis and returns descriptive captions. The resulting text is displayed and narrated through Flutter TTS.</p>
<p>The paper presents this as an integrated, context-aware experience rather than separate demos. The intended user experience is:</p>
<ol>
<li>Open the mobile app.</li>
<li>Capture or upload an image or video.</li>
<li>Let the system extract text and/or generate a description.</li>
<li>Read the result on screen.</li>
<li>Hear the result spoken aloud.</li>
</ol>
<p>The paper’s own figures show the system architecture (Fig. 1), mobile-to-API interaction (Fig. 2), the OCR and captioning pipeline (Fig. 3), and the text extraction workflow between Flutter and Flask (Fig. 4).</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW1VzZXIgd2l0aCBGbHV0dGVyIGFwcF0gLS0-IEJbSW5wdXQgbGF5ZXJdCiAgICBCIC0tPiBDW0ltYWdlIGNhcHR1cmUgb3IgdXBsb2FkXQogICAgQiAtLT4gRFtWaWRlbyB1cGxvYWRdCiAgICBDIC0tPiBFW0ZsYXNrIEFQSSBiYWNrZW5kXQogICAgc3ViZ3JhcGggUFtQcm9jZXNzaW5nXQogICAgICAgIGRpcmVjdGlvbiBMUgogICAgICAgIEUgLS0-IEZbT3BlbkNWIHByZXByb2Nlc3NpbmddIC0tPiBHW1Rlc3NlcmFjdCBPQ1JdCiAgICAgICAgRSAtLT4gSFtDTk4gYW5kIFJOTiBjYXB0aW9uaW5nXQogICAgZW5kCiAgICBEIC0tPiBJW0dvb2dsZSBTdHVkaW8gQVBJXQogICAgSSAtLT4gSltGcmFtZS1sZXZlbCBkZXNjcmlwdGlvbnNdCiAgICBHIC0tPiBLW0V4dHJhY3RlZCB0ZXh0XQogICAgSCAtLT4gTFtJbWFnZSBkZXNjcmlwdGlvbl0KICAgIEogLS0-IE1bQ29udGVudCBhZ2dyZWdhdGlvbl0KICAgIEsgLS0-IE0KICAgIEwgLS0-IE0KICAgIE0gLS0-IE5bRmx1dHRlciBUVFNdCiAgICBOIC0tPiBPW1Nwb2tlbiBuYXJyYXRpb25dCiAgICBNIC0tPiBRW09uLXNjcmVlbiB0ZXh0XQo=?type=png&amp;bgColor=white&amp;width=800" alt="High-level multimodal narration architecture showing the Flutter client, Flask backend, OpenCV preprocessing, Tesseract OCR, CNN-RNN captioning, Google Studio API for video, content aggregation, and spoken output" /></p>
<p><em>Figure 2. High-level architecture described in the paper, redrawn. The research design routes images through a Flask backend and video through an external frame-analysis API before narration.</em></p>
<p>The paper uses the <strong>TextOCR</strong> dataset, a scene-text benchmark of natural images annotated with dense text across font styles, sizes, orientations, languages, and backgrounds. It argues the dataset can support both OCR and captioning, although TextOCR is not a conventional captioning benchmark such as MS COCO. For speech, it uses pre-trained Google TTS models with configurable pitch, rate, and language; the methodology describes a CNN encoder, an RNN/LSTM decoder, attention, and beam search for image captioning.</p>
<p>Those are the research claims. The repository is a separate artifact and does not implement all of them.</p>
<h2>Technical architecture: paper versus repository</h2>
<p>The paper’s architecture is a client-server design. Flutter is the mobile client. The image path goes to a Flask API, which performs OpenCV preprocessing and Tesseract OCR and hosts a deep-learning captioning model. Video goes to an external service. Firebase stores structured output. Flutter TTS handles narration.</p>
<p>The repository is a mobile-first, on-device design. It uses Flutter for the UI, Firebase Authentication for sign-in, Cloud Firestore for history, Google ML Kit for image labeling and text recognition, and <code>flutter_tts</code> for speech. There is no Flask server, no HTTP client for a custom backend, no Tesseract dependency, no TensorFlow dependency, and no video input path in the Dart code.</p>
<p>That difference changes where data is processed, what works offline, what leaves the device, and which parts of the paper’s evaluation apply to the shipped app.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>IEEE paper</th>
<th>Current repository</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>OCR</td>
<td>Tesseract behind a Flask API with OpenCV preprocessing</td>
<td>Google ML Kit <code>TextRecognizer</code>, Latin script, on-device</td>
<td>Different</td>
</tr>
<tr>
<td>TTS</td>
<td>Google TTS API through <code>flutter_tts</code>; OCR text and caption concatenated</td>
<td><code>flutter_tts</code>, <code>en-US</code>, rate 0.5; only OCR text spoken</td>
<td>Partial</td>
</tr>
<tr>
<td>Image captioning</td>
<td>CNN encoder, RNN/LSTM decoder, attention, beam search</td>
<td>ML Kit <code>ImageLabeler</code>; top labels joined into a sentence</td>
<td>Different</td>
</tr>
<tr>
<td>Video captioning</td>
<td>Frame-wise processing through an external API</td>
<td>No video capture, playback, or captioning path</td>
<td>Not implemented</td>
</tr>
<tr>
<td>Multimodal integration</td>
<td>OCR text and caption combined into one narration string</td>
<td>Both displayed; narration uses OCR text only</td>
<td>Partial</td>
</tr>
<tr>
<td>User interface</td>
<td>Flutter mobile app</td>
<td>Flutter intro, auth, gallery, result, and history screens</td>
<td>Verified</td>
</tr>
<tr>
<td>Storage</td>
<td>JSON output stored in Firebase</td>
<td>Firestore timestamp and OCR result per user</td>
<td>Partial</td>
</tr>
<tr>
<td>Evaluation</td>
<td>OCR/TTS metrics, user study, training curves</td>
<td>No evaluation code</td>
<td>Not reproduced</td>
</tr>
</tbody></table>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW21haW4uZGFydCBlbnRyeV0gLS0-IEJbRmlyZWJhc2UgaW5pdF0KICAgIEIgLS0-IEN7QXV0aCBzdGF0ZX0KICAgIEMgLS0-fFNpZ25lZCBpbnwgRFtHYWxsZXJ5IHNjcmVlbl0KICAgIEMgLS0-fFNpZ25lZCBvdXR8IEVbSW50cm8gc2NyZWVuXQogICAgRSAtLT4gRltMb2dpbiBvciBzaWdudXBdCiAgICBGIC0tPiBECiAgICBEIC0tPiBHW0NhbWVyYSBvciBnYWxsZXJ5XQogICAgc3ViZ3JhcGggUFtNTCBLaXQgcHJvY2Vzc2luZ10KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBHIC0tPiBIW0ltYWdlIGxhYmVsZXJdCiAgICAgICAgRyAtLT4gSVtUZXh0IHJlY29nbml6ZXJdCiAgICBlbmQKICAgIEggLS0-IEpbUmVzdWx0IHNjcmVlbl0KICAgIEkgLS0-IEoKICAgIEogLS0-IEtbRmx1dHRlciBUVFNdCiAgICBKIC0tPiBMW0ZpcmVzdG9yZSBoaXN0b3J5XQogICAgRCAtLT4gTVtEcmF3ZXJdCiAgICBNIC0tPiBOW0hpc3Rvcnkgc2NyZWVuXQogICAgTSAtLT4gT1tMb2dvdXRdCg==?type=png&amp;bgColor=white&amp;width=900" alt="Flutter application architecture showing Firebase initialization, authentication branching, gallery screen, ML Kit image labeling and text recognition, result screen, TTS, Firestore history, and drawer navigation" /></p>
<p><em>Figure 3. The repository’s Flutter application structure, based on the actual screen and dependency graph. This is not the paper’s Flask-based architecture.</em></p>
<h2>OCR: from preprocessing to extracted text</h2>
<p>The paper’s OCR pipeline starts with a camera or gallery image, then applies grayscale conversion, adaptive thresholding, denoising, and resizing. Tesseract extracts the text, which is post-processed and passed to TTS. The paper’s Fig. 4 shows the Flutter-to-Flask request and JSON response.</p>
<p>The repository takes a different route. In <code>lib/Screens/gallery.dart</code>, <code>_recognizeText</code> creates an <code>InputImage</code> and runs ML Kit’s <code>TextRecognizer</code>:</p>
<pre><code class="language-dart">final inputImage = InputImage.fromFilePath(image.path);
final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);

try {
  final RecognizedText recognizedText =
      await textRecognizer.processImage(inputImage);
  setState(() { _text = recognizedText.text; });
  Get.to(() =&gt; ResultView(
    text: _text, image: image, description: description,
  ));
} catch (e) {
  setState(() { _text = 'Error recognizing text.'; });
} finally {
  textRecognizer.close();
}
</code></pre>
<p>This is genuine on-device OCR through Google ML Kit. There is no OpenCV preprocessing in the Dart code, no Tesseract call, and no Flask request. The only language configuration is Latin script. The Android Gradle file includes additional ML Kit language packages for Chinese, Devanagari, Japanese, and Korean, but the Dart recognizer is still initialized for Latin, so those packages are not exercised by this path.</p>
<p>The paper’s 90.96% OCR accuracy belongs to a Tesseract pipeline with preprocessing, not the repository’s ML Kit path.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW0ltYWdlIGlucHV0XSAtLT4gUwogICAgc3ViZ3JhcGggU1tQcmVwcm9jZXNzaW5nXQogICAgICAgIGRpcmVjdGlvbiBMUgogICAgICAgIEJbR3JheXNjYWxlXSAtLT4gQ1tBZGFwdGl2ZSB0aHJlc2hvbGRdIC0tPiBEW0Rlbm9pc2UgYW5kIHJlc2l6ZV0KICAgIGVuZAogICAgRCAtLT4gRVtUZXNzZXJhY3QgT0NSXQogICAgc3ViZ3JhcGggUltSZWNvZ25pdGlvbiBvdXRwdXRdCiAgICAgICAgZGlyZWN0aW9uIExSCiAgICAgICAgRltQb3N0LXByb2Nlc3MgdGV4dF0gLS0-IEdbSlNPTiByZXNwb25zZV0gLS0-IEhbRGlzcGxheSBhbmQgVFRTXQogICAgZW5kCiAgICBFIC0tPiBGCg==?type=png&amp;bgColor=white&amp;width=500" alt="OCR processing workflow showing image input, grayscale conversion, adaptive thresholding, denoising and resizing, Tesseract OCR, post-processing, JSON output, and TTS" /></p>
<p><em>Figure 4. OCR workflow described in the paper, redrawn. The repository replaces Tesseract and Flask with on-device ML Kit text recognition.</em></p>
<h2>Text-to-Speech: turning the result into audio</h2>
<p>The paper says OCR text and the generated image caption are concatenated into one string and passed to TTS. It mentions voice pitch, speaking rate, and language settings, plus a stop-token prediction mechanism to detect the end of narration.</p>
<p>The repository uses <code>flutter_tts</code> directly. In <code>lib/Screens/result.dart</code>, the result screen configures English (United States), rate 0.5, and volume 100:</p>
<pre><code class="language-dart">FlutterTts flutterTts = FlutterTts();

_initTts() async {
  await flutterTts.setLanguage("en-US");
  await flutterTts.setSpeechRate(0.5);
  await flutterTts.setVolume(100);
}

void _speak(String text) async {
  await flutterTts.speak(text);
}
</code></pre>
<p>Two differences matter. First, narration is triggered by a <strong>Narrate</strong> button; it does not speak automatically when the result screen opens. Second, <code>_speak(widget.text)</code> passes only OCR text. The image description is displayed but not spoken. The result screen is multimodal in presentation, but not fully multimodal in narration.</p>
<p>The volume value is also worth flagging. <code>flutter_tts</code> commonly expects a normalized volume between 0.0 and 1.0, depending on the platform implementation. Passing <code>100</code> is unusual and should be verified on a physical device. The app does not expose voice selection, pitch control, speed control, pause/resume, or a visible stop control beyond calling <code>flutterTts.stop()</code> in <code>dispose()</code>.</p>
<p>The paper describes the Google TTS API, while <code>flutter_tts</code> wraps the platform’s native speech engine (often Google’s on Android, the system synthesizer on iOS). The repository has no Google Cloud TTS client or credentials.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW09DUiB0ZXh0IGFuZCBpbWFnZSBjYXB0aW9uXSAtLT4gUwogICAgc3ViZ3JhcGggU1tQcmVwYXJhdGlvbl0KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBCW0NvbmNhdGVuYXRlIGNvbnRlbnRdIC0tPiBDW1NlbGVjdCBsYW5ndWFnZSBhbmQgdm9pY2VdCiAgICBlbmQKICAgIEMgLS0-IERbRmx1dHRlciBUVFMgZW5naW5lXQogICAgRCAtLT4gRVtDb25maWd1cmUgcGl0Y2ggYW5kIHJhdGVdCiAgICBFIC0tPiBGW1NwZWVjaCBzeW50aGVzaXNdCiAgICBGIC0tPiBHW0RldmljZSBzcGVha2VyXQo=?type=png&amp;bgColor=white&amp;width=500" alt="Text-to-speech pipeline showing OCR text and image caption, content concatenation, language and voice selection, Flutter TTS configuration, speech synthesis, and device speaker output" /></p>
<p><em>Figure 5. TTS pipeline described in the paper, redrawn. The repository implements a simpler button-triggered flow and speaks only the OCR text.</em></p>
<h2>Image captioning: a research model versus a labeler</h2>
<p>The paper’s image-captioning approach follows an encoder-decoder pattern. A CNN encodes the image into visual features. An RNN, typically with LSTM cells, decodes them into a sentence. Attention focuses on spatially relevant regions, and beam search selects a likely sequence during inference. The paper cites “Show and Tell” as background and includes the caption in the combined narration.</p>
<p>The repository has no CNN-RNN captioning model. Instead, <code>getImageDescription</code> in <code>lib/Screens/gallery.dart</code> uses ML Kit’s <code>ImageLabeler</code> with a confidence threshold of 0.7 and joins up to five labels:</p>
<pre><code class="language-dart">final options = ImageLabelerOptions(confidenceThreshold: 0.7);
final labeler = ImageLabeler(options: options);

final List&lt;ImageLabel&gt; labels = await labeler.processImage(inputImage);
final topLabels = labels.take(5).toList();
String description = "The image contains ";
for (int i = 0; i &lt; topLabels.length; i++) {
  description += topLabels[i].label;
  if (i &lt; topLabels.length - 1) description += ", ";
}
return description;
</code></pre>
<p>This produces output such as “The image contains table, cup, text, indoor, wood.” It is a label list, not a grammatical caption. It does not describe relationships, actions, or spatial layout. It also inherits the labeler’s vocabulary and confidence behavior. A user may learn that a cup and a table are present, but not that the cup is on the table or that a person is reaching for it.</p>
<p>The README says “object detection” and “image captioning,” but the code uses image labeling; the paper describes a trained attention-based captioning model. Labels, object detection, and generative captioning are different levels of visual understanding and should not be conflated.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW0lucHV0IGltYWdlXSAtLT4gUwogICAgc3ViZ3JhcGggU1tFbmNvZGVyXQogICAgICAgIGRpcmVjdGlvbiBMUgogICAgICAgIEJbQ05OXSAtLT4gQ1tWaXN1YWwgZmVhdHVyZXNdCiAgICBlbmQKICAgIEMgLS0-IFQKICAgIHN1YmdyYXBoIFRbRGVjb2Rlcl0KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBEW0F0dGVudGlvbl0gLS0-IEVbTFNUTV0gLS0-IEZbQmVhbSBzZWFyY2hdCiAgICBlbmQKICAgIEYgLS0-IEdbR2VuZXJhdGVkIGNhcHRpb25dCiAgICBHIC0tPiBIW0Z1c2Ugd2l0aCBPQ1IgdGV4dF0KICAgIEggLS0-IElbVFRTIG5hcnJhdGlvbl0K?type=png&amp;bgColor=white&amp;width=600" alt="Image captioning workflow showing CNN encoder, visual feature map, attention mechanism, LSTM decoder, beam search, generated caption, fusion with OCR text, and TTS narration" /></p>
<p><em>Figure 6. Image-captioning pipeline described in the paper, redrawn. The repository substitutes ML Kit image labels for the CNN-RNN captioning model.</em></p>
<h2>Video captioning: temporal understanding, not a still image</h2>
<p>The paper’s video path is described as frame-wise analysis through the Google Studio API. Each processed frame yields a textual description, which can be shown or passed to TTS. The paper’s Fig. 3 presents OCR and image/video captioning as a shared workflow, with the server returning descriptive text to the app.</p>
<p>The paper names the service inconsistently: <strong>Google Studio API</strong> in the overview, <strong>Google Cloud Vision API</strong> in the Fig. 2 discussion, and <strong>Google Studio Server</strong> in Fig. 3. Its future-work section mentions Google AI Studio’s Gemini Vision API, suggesting the video path was still evolving.</p>
<p>The repository does not implement video: no picker, player, frame extraction, upload path, or captioning service in <code>pubspec.yaml</code> or <code>lib/</code>. The README lists “video description features” as a future direction, consistent with the code.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW1ZpZGVvIGlucHV0XSAtLT4gUwogICAgc3ViZ3JhcGggU1tGcmFtZSBhbmFseXNpc10KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBCW0ZyYW1lIGV4dHJhY3Rpb25dIC0tPiBDW0ZyYW1lLXdpc2UgYW5hbHlzaXNdCiAgICBlbmQKICAgIEMgLS0-IERbR29vZ2xlIFN0dWRpbyBBUEldCiAgICBEIC0tPiBFW1NjZW5lIGRlc2NyaXB0aW9uc10KICAgIEUgLS0-IEZbQWdncmVnYXRlIGNhcHRpb25zXQogICAgRiAtLT4gR1tGbHV0dGVyIFRUU10KICAgIEcgLS0-IEhbQXVkaW8gbmFycmF0aW9uXQo=?type=png&amp;bgColor=white&amp;width=500" alt="Video captioning workflow showing video input, frame extraction, frame-wise analysis, Google Studio API, scene-level descriptions, caption aggregation, Flutter TTS, and audio narration" /></p>
<p><em>Figure 7. Video-captioning workflow described in the paper, redrawn. This path is not present in the current repository.</em></p>
<h2>How the modalities connect</h2>
<p>The paper’s integration story is simple: OCR text and image caption are concatenated into a single string, and TTS narrates the combined content. That is a pragmatic fusion strategy. It does not require a learned multimodal model; it treats text and caption as two textual channels and lets speech deliver both.</p>
<p>The repository’s integration is weaker. The result screen displays the image, the label-based description, and the OCR text. The “Narrate” button speaks only the OCR text. The user therefore has to read the description visually or rely on a screen reader to reach it. For an accessibility app, that is a significant gap: the visual description is present in the interface but not in the primary audio path.</p>
<p>A more complete fusion needs an ordering policy, deduplication, and uncertainty handling—for example, “Text reads: …” then “Scene description: …” and “Some details may be approximate.” The paper describes concatenation but no conflict policy; the repository does not concatenate in speech.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW1VzZXIgb3BlbnMgQXJub3hdIC0tPiBCe0lucHV0IHR5cGV9CiAgICBCIC0tPnxDYW1lcmEgb3IgZ2FsbGVyeSBpbWFnZXwgQ1tJbWFnZSBwYXRoXQogICAgQiAtLT58VmlkZW8gdXBsb2FkfCBEW1ZpZGVvIHBhdGhdCiAgICBzdWJncmFwaCBQW1Byb2Nlc3NpbmddCiAgICAgICAgZGlyZWN0aW9uIExSCiAgICAgICAgQyAtLT4gRVtJbWFnZSBsYWJlbGluZyBhbmQgT0NSXQogICAgICAgIEQgLS0-IEZbRnJhbWUtd2lzZSBjYXB0aW9uaW5nXQogICAgZW5kCiAgICBFIC0tPiBHW0NvbWJpbmVkIHJlc3VsdF0KICAgIEYgLS0-IEcKICAgIEcgLS0-IEhbUmVzdWx0IHNjcmVlbl0KICAgIEggLS0-IElbRmx1dHRlciBUVFNdCiAgICBIIC0tPiBKW0ZpcmViYXNlIGhpc3RvcnldCiAgICBJIC0tPiBLW1VzZXIgaGVhcnMgYW5kIHJlYWRzXQo=?type=png&amp;bgColor=white&amp;width=600" alt="End-to-end user interaction showing app launch, input choice, image or video path, processing, combined result, result screen, TTS, Firebase history, and user feedback" /></p>
<p><em>Figure 8. End-to-end interaction model. The solid path reflects the repository’s implemented image workflow; the video branch reflects the paper’s proposed path.</em></p>
<h2>The actual end-to-end user workflow</h2>
<p>The current flow is visible in the screen files:</p>
<ol>
<li><code>lib/main.dart</code> initializes Firebase and starts the app.</li>
<li>A signed-in user opens <code>Gallery</code>; otherwise the app opens <code>Intro</code>.</li>
<li>Intro leads to login or sign-up, both using Firebase email/password authentication.</li>
<li><code>Gallery</code> offers <strong>Take a Picture</strong> and <strong>Gallery</strong>, both using <code>image_picker</code>.</li>
<li>After selection, <code>getImageDescription</code> runs ML Kit image labeling, then <code>_recognizeText</code> runs ML Kit OCR.</li>
<li><code>ResultView</code> shows the image, label-based description, and OCR text.</li>
<li><strong>Narrate</strong> calls <code>flutter_tts.speak</code> with the OCR text.</li>
<li><code>storeToFirebase()</code> runs inside <code>build()</code>, writing OCR text and a timestamp to the user’s Firestore history.</li>
<li>The drawer opens history, which streams saved documents and lets the user reopen a result in a second result screen.</li>
</ol>
<p>This is a real, navigable workflow, but narrower than the paper. There is no visible preprocessing stage, no video branch, no automatic narration, and no combined OCR-plus-description speech.</p>
<p>An illustrative walkthrough: a user photographs a printed notice. The labeler recognizes “paper,” “text,” and “indoor,” while OCR extracts the words. The result screen shows the photo, labels, and text; Narrate speaks only the extracted text. The description stays visual, which shows both the value and the current limit.</p>
<h2>Flutter implementation details</h2>
<p><code>lib/main.dart</code> initializes Firebase, wraps the app in <code>ScreenUtilInit</code>, and uses <code>GetMaterialApp</code> for navigation:</p>
<pre><code class="language-dart">void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const ITSpeech());
}
</code></pre>
<p><code>DefaultFirebaseOptions</code> points to <code>firebase_options.dart</code>, which is gitignored. A fresh clone must generate it with the FlutterFire CLI and add <code>google-services.json</code> on Android; the repository is not a one-command runnable demo without setup.</p>
<p>The UI uses conventional Material widgets (<code>Scaffold</code>, <code>AppBar</code>, <code>Drawer</code>, <code>TextButton</code>, <code>TextFormField</code>), <code>flutter_screenutil</code> for sizing, and GetX mainly for navigation, not reactive state. Screens use <code>StatefulWidget</code> and <code>setState</code>.</p>
<p>There is no service layer: ML Kit calls live in <code>gallery.dart</code>, TTS in <code>result.dart</code> and <code>resultView.dart</code>, and Firestore writes in <code>result.dart</code>. The prototype is easy to follow but tightly couples UI, plugins, and storage.</p>
<p>Error handling is minimal. <code>_recognizeText</code> catches exceptions and sets an error string, but <code>_pickImage</code> and <code>_clickImage</code> use <code>image!</code> after <code>ImagePicker().pickImage</code>, which throws if the user cancels. Firebase auth flows catch <code>FirebaseAuthException</code> in limited ways. There is no retry, timeout, or recovery path for ML failures.</p>
<p>The Android manifest declares <code>READ_EXTERNAL_STORAGE</code> but not <code>CAMERA</code>; <code>image_picker</code> usually launches the system camera through an intent. The iOS <code>Info.plist</code> lacks <code>NSCameraUsageDescription</code> and <code>NSPhotoLibraryUsageDescription</code>, so camera and photo access will fail until those keys are added. The main Android manifest also lacks <code>INTERNET</code>; debug and profile have it, but a release build using Firebase would need it in main.</p>
<h2>Technology stack and design choices</h2>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Role</th>
<th>Why it fits</th>
<th>Evidence</th>
</tr>
</thead>
<tbody><tr>
<td>Flutter and Dart</td>
<td>Cross-platform UI</td>
<td>One codebase for Android and iOS; fast iteration</td>
<td><code>pubspec.yaml</code>, <code>lib/</code></td>
</tr>
<tr>
<td>Google ML Kit text recognition</td>
<td>On-device OCR</td>
<td>No custom backend; Latin and multi-language packages</td>
<td><code>google_ml_kit: ^0.19.0</code>, <code>gallery.dart</code></td>
</tr>
<tr>
<td>Google ML Kit image labeling</td>
<td>Visual labels</td>
<td>Lightweight on-device visual understanding</td>
<td><code>ImageLabeler</code>, <code>gallery.dart</code></td>
</tr>
<tr>
<td><code>flutter_tts</code></td>
<td>Speech output</td>
<td>Cross-platform TTS with language, rate, volume</td>
<td><code>flutter_tts: ^4.0.2</code>, <code>result.dart</code></td>
</tr>
<tr>
<td>Firebase Authentication</td>
<td>User accounts</td>
<td>Email/password sign-in without an auth backend</td>
<td><code>firebase_auth: ^5.3.1</code></td>
</tr>
<tr>
<td>Cloud Firestore</td>
<td>Per-user history</td>
<td>Stores text and timestamps in nested collections</td>
<td><code>cloud_firestore: ^5.4.4</code></td>
</tr>
<tr>
<td>GetX</td>
<td>Navigation</td>
<td>Simple route transitions; not the primary state manager</td>
<td><code>get: ^4.6.6</code></td>
</tr>
<tr>
<td><code>flutter_screenutil</code></td>
<td>Responsive sizing</td>
<td>Scales dimensions across screen sizes</td>
<td><code>flutter_screenutil: ^5.9.3</code></td>
</tr>
<tr>
<td><code>image_picker</code></td>
<td>Camera and gallery</td>
<td>Standard Flutter media picker</td>
<td><code>image_picker: ^1.1.2</code></td>
</tr>
</tbody></table>
<p>The stack fits a mobile prototype: on-device ML reduces backend cost, Firebase removes server management, and Flutter covers two platforms. The trade-off is ML Kit’s label vocabulary, the platform TTS engine, and Firebase’s network dependency. The paper’s Flask, OpenCV, Tesseract, TensorFlow, and cloud-video stack suits a research pipeline where the team controls preprocessing and model architecture, but adds a server, latency, deployment cost, and data-handling questions.</p>
<h2>Data flow and model interaction</h2>
<p>The repository’s data flow is local-first for image understanding:</p>
<ol>
<li>The user selects an image from the camera or gallery.</li>
<li><code>ImagePicker</code> returns a file path.</li>
<li><code>InputImage.fromFilePath</code> prepares the image for ML Kit.</li>
<li>The labeler returns labels; the text recognizer returns OCR text.</li>
<li>Results pass to the result screen as widget properties.</li>
<li><code>flutter_tts</code> synthesizes speech from OCR text.</li>
<li>Firestore stores OCR text and timestamp under the signed-in user.</li>
</ol>
<p>The image itself is not uploaded by the Dart code, a privacy advantage over the paper’s Flask path. The OCR text is stored in Firestore, however, so sensitive text can become cloud data. The repository has no visible privacy notice, retention control, or delete-history action. The paper’s flow sends the image to Flask, runs preprocessing and OCR on the server, returns JSON, and stores the result in Firebase; video goes to an external API. Those boundaries create network dependency and require trust in the backend and cloud provider.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW0NhbWVyYSBvciBnYWxsZXJ5XSAtLT4gQltGbHV0dGVyIGNsaWVudF0KICAgIEIgLS0-IENbR29vZ2xlIE1MIEtpdF0KICAgIHN1YmdyYXBoIFBbT24tZGV2aWNlIG91dHB1dF0KICAgICAgICBkaXJlY3Rpb24gTFIKICAgICAgICBDIC0tPiBEW09DUiB0ZXh0XQogICAgICAgIEMgLS0-IEVbSW1hZ2UgbGFiZWxzXQogICAgZW5kCiAgICBEIC0tPiBGW1Jlc3VsdCBzdGF0ZV0KICAgIEUgLS0-IEYKICAgIEYgLS0-IEdbRmx1dHRlciBUVFNdCiAgICBGIC0tPiBIW0ZpcmVzdG9yZSBoaXN0b3J5XQogICAgRyAtLT4gSVtTcGVha2VyIG91dHB1dF0KICAgIEggLS0-IEpbSGlzdG9yeSBzY3JlZW5dCiAgICBCIC0uLT58UGFwZXIgZGVzaWduIG9ubHl8IEtbRmxhc2sgQVBJXQogICAgSyAtLi0-IExbVGVzc2VyYWN0IGFuZCBDTk4tUk5OXQogICAgTCAtLi0-fEpTT058IEIK?type=png&amp;bgColor=white&amp;width=800" alt="Data-flow diagram showing the repository’s on-device ML Kit path, Flutter TTS, Firestore history, and the paper’s dashed Flask/Tesseract/CNN-RNN path" /></p>
<p><em>Figure 9. Data flow comparison. Solid lines show the repository’s implemented path; dashed lines show the paper’s design that is not present in the current Dart code.</em></p>
<h2>Experimental evaluation and research findings</h2>
<p>The paper reports the following OCR results in Section V:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Reported value</th>
</tr>
</thead>
<tbody><tr>
<td>Accuracy</td>
<td>90.96%</td>
</tr>
<tr>
<td>Precision</td>
<td>94.62%</td>
</tr>
<tr>
<td>Recall</td>
<td>90.98%</td>
</tr>
<tr>
<td>F1-score</td>
<td>92.77%</td>
</tr>
</tbody></table>
<p>The TTS module received a mean intelligibility score of <strong>4.14 out of 5</strong> in user testing. The paper says participants cited clarity, fluency, and reduced latency as strengths. It also reports that <strong>82% of participants</strong> experienced a marked improvement in interpreting visual content.</p>
<p>The training discussion covers 20 epochs. Training accuracy rises from about <strong>55% to nearly 98%</strong>, while validation accuracy climbs from just under <strong>50% to approximately 95%</strong>. Training loss starts around <strong>0.9</strong> and drops to <strong>under 0.1</strong>, with validation loss following a similar trend and minimal variance. The paper interprets the narrow gap as evidence of good generalization.</p>
<p>These are the publication’s numbers. Some cautions:</p>
<ul>
<li>The paper does not state the OCR test-set size, train/validation/test split, or exact evaluation protocol.</li>
<li>It does not report captioning metrics such as BLEU, METEOR, CIDEr, or SPICE, even though captioning is central.</li>
<li>The user study’s participant count, recruitment, and task design are not reported, so the 82% and 4.14/5 figures cannot be independently reconstructed.</li>
<li>The paper claims real-time operation but provides no latency table or measured end-to-end response time.</li>
<li>TextOCR is a scene-text benchmark, not a standard captioning dataset. Using it for both tasks is unusual and is not justified with caption-specific evaluation.</li>
</ul>
<p>The repository does not reproduce these experiments. There is no evaluation script, test dataset, metrics calculation, or benchmark harness. The only test file is the default Flutter counter smoke test, which does not match the app and would fail. The paper’s results belong to the research pipeline, not the current app.</p>
<h2>Accessibility, usability, and design considerations</h2>
<p>Accessibility is not just “add TTS.” Output must be understandable, controllable, and timely. The paper aims for multi-sensory feedback—text on screen and speech through the speaker—which supports different preferences and residual vision. A narration system also needs clear structure (is this text, a description, or an error?), control (pause, repeat, stop, speed), announced loading and failure states, predictable latency, privacy, and accessible controls with meaningful labels and adequate touch targets.</p>
<p>The repository has basic Material controls, but no explicit <code>Semantics</code> labels, focus management, haptics, or automatic result announcement. The visual result is not spoken, and storing every result in Firestore from <code>build()</code> can create duplicate entries. The paper does not claim WCAG conformance, and neither should this article; WCAG 2.2 and Flutter’s guidance are useful references, but a checklist is not user validation.</p>
<h2>Engineering challenges and trade-offs</h2>
<p><strong>OCR accuracy is input-dependent.</strong> The paper’s preprocessing targets poor lighting and irregular alignment; the repository relies on ML Kit without custom preprocessing, so glare, rotation, small fonts, and complex layouts remain risks.</p>
<p><strong>Labels are not captions.</strong> ML Kit labels do not express relationships. “Cup, table, indoor” is not “a cup on a table beside a window”; quality is bounded by vocabulary and confidence.</p>
<p><strong>Narration is incomplete.</strong> The app speaks OCR text but not the image description. The paper’s concatenation approach is a better baseline, but it still needs ordering and uncertainty handling.</p>
<p><strong>Video and networking are costly.</strong> Frame-wise video analysis multiplies inference cost, payload, and latency; the repository omits it. The paper’s Flask/cloud design needs connectivity and a running backend, while the repository’s on-device image path is better for privacy but Firebase still needs network.</p>
<p><strong>Platform configuration matters.</strong> Missing iOS usage descriptions, a release manifest without <code>INTERNET</code>, and a gitignored Firebase options file can turn a working prototype into a setup puzzle.</p>
<p><strong>The result screen writes during build.</strong> <code>storeToFirebase()</code> runs in <code>build()</code>, so every rebuild can create another document. Moving it to <code>initState</code>, a post-frame callback, or an explicit save action would be safer.</p>
<p><strong>UI and ML are tightly coupled.</strong> ML Kit and TTS calls are embedded in widgets. A service layer would make testing and model swaps easier, and the duplicate <code>ResultView</code>/<code>Resultview</code> screens increase maintenance risk.</p>
<h2>Limitations and future improvements</h2>
<p>The publication’s limitations include processing latency, multilingual adaptability, and real-time interaction. It also notes that noisy or distorted images are a challenge and that diffusion-based enhancement methods such as anisotropic diffusion and unsharp masking could be integrated later. It proposes faster deep-learning models, gesture commands, multilingual support, context-aware natural language understanding, and improved video summarization.</p>
<p>The repository’s limitations are more concrete: no video path, no generative captioning, no combined narration, no automatic narration, no evaluation harness, no explicit accessibility semantics, no privacy controls, minimal error handling, no service-layer separation, and duplicate Firestore writes.</p>
<p>Reasonable improvements, clearly proposals rather than existing features:</p>
<ol>
<li><strong>Fuse the narration.</strong> Concatenate OCR text and the image description with clear spoken labels and an uncertainty phrase.</li>
<li><strong>Upgrade the description stage.</strong> Replace label lists with a grounded captioning model or a vision-language model with a confidence signal.</li>
<li><strong>Add video sampling and aggregation.</strong> Extract key frames, caption them, deduplicate similar descriptions, and narrate a short summary.</li>
<li><strong>Add preprocessing.</strong> Try deskewing, contrast enhancement, and region-of-interest cropping before OCR.</li>
<li><strong>Improve accessibility semantics.</strong> Add <code>Semantics</code> labels, focus order, haptic cues, and spoken loading/error states.</li>
<li><strong>Add playback controls.</strong> Pause, resume, stop, repeat, and speed control should be first-class.</li>
<li><strong>Move side effects out of <code>build()</code>.</strong> Save history explicitly or in <code>initState</code>.</li>
<li><strong>Add privacy controls.</strong> Let users delete history, avoid storing sensitive text by default, and explain where processing happens.</li>
<li><strong>Evaluate properly.</strong> Report OCR accuracy on a defined test set, captioning metrics on a captioning benchmark, end-to-end latency, and a user study with a described protocol.</li>
<li><strong>Test on real devices.</strong> Verify iOS permissions, Android release networking, TTS volume behavior, and camera/gallery flows.</li>
</ol>
<p>None of these improvements are claimed as implemented.</p>
<h2>What developers can learn</h2>
<p>The project is a useful case study in building an assistive AI application because it exposes the gap between a research pipeline and a mobile product.</p>
<p><strong>Separate the research claim from the runtime.</strong> A paper can describe Tesseract, Flask, and a CNN-RNN model while the app uses ML Kit and a labeler. Both can be legitimate, but they are not the same system. Document the difference.</p>
<p><strong>Design for model uncertainty.</strong> OCR returns text even when it is wrong. Labelers return labels even when they are weak. An accessibility interface should communicate confidence and give the user a way to verify or repeat.</p>
<p><strong>Treat TTS as an interaction, not an output.</strong> Speaking once is not enough. Users need control over speed, repetition, and stopping. Narration should be integrated into the workflow, not hidden behind an extra tap.</p>
<p><strong>Keep privacy visible.</strong> On-device processing is a strong default for personal images. If text is stored in the cloud, say so and provide deletion.</p>
<p><strong>Build a service boundary.</strong> ML, storage, and speech should be services behind interfaces. That makes it possible to swap ML Kit for Tesseract, or a labeler for a captioning model, without rewriting screens.</p>
<p><strong>Test the boring failure paths.</strong> Cancelled pickers, denied permissions, missing Firebase config, offline auth, and empty OCR results are the paths users actually hit.</p>
<h2>Explore the original IEEE research</h2>
<p>The full paper is the right place to go for the research methodology, system description, dataset discussion, OCR and TTS results, training curves, and the authors’ own limitations and future work. It also contains the original architecture figures and bibliographic details.</p>
<p><strong>Publication:</strong> Swagat Kumar Samantaray, Mansi Kaushik, Vishal Kumar Ojha, Rishav Mishra, and Abhishek Kumar, “Multimodal Narration System for Enhancing Accessibility for the Visually Impaired,” <em>2025 International Conference on Cognitive, Green and Ubiquitous Computing (IC-CGU)</em>, Bhubaneswar, India, Oct. 10–11, 2025, pp. 1–6, doi: <a href="https://doi.org/10.1109/IC-CGU67042.2025.11337992">10.1109/IC-CGU67042.2025.11337992</a>.</p>
<p><strong>IEEE Xplore:</strong> <a href="https://ieeexplore.ieee.org/document/11337992">https://ieeexplore.ieee.org/document/11337992</a></p>
<p><strong>Repository:</strong> <a href="https://github.com/vishalkumarojha/ArnoX">https://github.com/vishalkumarojha/ArnoX</a></p>
<p>If you want to explore the complete methodology and research findings, read the original IEEE publication. The repository is best read alongside it: the paper explains the research intent, and the code shows what the current mobile implementation actually does.</p>
<h2>Conclusion</h2>
<p>The accessibility problem is not just “make text readable.” It is “make visual information available through a modality the user can use.” OCR, captioning, video understanding, and TTS each answer a different part of that problem. Combining them is where the engineering becomes interesting, and where the hard trade-offs appear: accuracy versus latency, on-device privacy versus cloud capability, rich description versus model uncertainty, and research ambition versus what ships.</p>
<p>The IEEE paper proposes a multimodal narration system that connects these pieces into one accessibility workflow. The ArnoX repository implements a focused, on-device slice of that idea: ML Kit OCR, ML Kit image labels, Flutter TTS, Firebase authentication, and per-user history. It is useful, but it is not the full paper, and the differences are worth understanding rather than hiding.</p>
<p>That gap is the most valuable lesson. A research prototype shows what is possible; an application shows what is practical today. The next step is to close the distance: complete the narration, add video carefully, measure what works, and build controls that make the output usable daily.</p>
<h2>References and further reading</h2>
<ol>
<li>S. K. Samantaray, M. Kaushik, V. K. Ojha, R. Mishra, and A. Kumar, “Multimodal Narration System for Enhancing Accessibility for the Visually Impaired,” in <em>Proc. 2025 Int. Conf. Cognitive, Green and Ubiquitous Computing (IC-CGU)</em>, Bhubaneswar, India, Oct. 2025, pp. 1–6, doi: <a href="https://doi.org/10.1109/IC-CGU67042.2025.11337992">10.1109/IC-CGU67042.2025.11337992</a>.</li>
<li>IEEE Xplore, “Multimodal Narration System for Enhancing Accessibility for the Visually Impaired,” <a href="https://ieeexplore.ieee.org/document/11337992">https://ieeexplore.ieee.org/document/11337992</a>.</li>
<li>ArnoX repository, <a href="https://github.com/vishalkumarojha/ArnoX">https://github.com/vishalkumarojha/ArnoX</a>.</li>
<li>Flutter documentation, <a href="https://docs.flutter.dev/">https://docs.flutter.dev/</a>.</li>
<li>Flutter accessibility guidance, <a href="https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility">https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility</a>.</li>
<li>Google ML Kit, “Text recognition,” <a href="https://developers.google.com/ml-kit/vision/text-recognition/v2">https://developers.google.com/ml-kit/vision/text-recognition/v2</a>.</li>
<li>Google ML Kit, “Image labeling,” <a href="https://developers.google.com/ml-kit/vision/image-labeling">https://developers.google.com/ml-kit/vision/image-labeling</a>.</li>
<li><code>flutter_tts</code> package, <a href="https://pub.dev/packages/flutter_tts">https://pub.dev/packages/flutter_tts</a>.</li>
<li><code>image_picker</code> package, <a href="https://pub.dev/packages/image_picker">https://pub.dev/packages/image_picker</a>.</li>
<li>Firebase for Flutter, <a href="https://firebase.google.com/docs/flutter/setup">https://firebase.google.com/docs/flutter/setup</a>.</li>
<li>Google Cloud Text-to-Speech, <a href="https://cloud.google.com/text-to-speech">https://cloud.google.com/text-to-speech</a>.</li>
<li>Tesseract OCR, <a href="https://github.com/tesseract-ocr/tesseract">https://github.com/tesseract-ocr/tesseract</a>.</li>
<li>O. Vinyals et al., “Show and Tell: A Neural Image Caption Generator,” in <em>Proc. IEEE CVPR</em>, 2015, pp. 3156–3164.</li>
<li>TextOCR dataset, <a href="https://www.kaggle.com/datasets/robikscube/textocr-text-extraction-from-images-dataset">https://www.kaggle.com/datasets/robikscube/textocr-text-extraction-from-images-dataset</a>.</li>
<li>World Health Organization, “Blindness and visual impairment,” <a href="https://www.who.int/news-room/fact-sheets/detail/blindness-and-visual-impairment">https://www.who.int/news-room/fact-sheets/detail/blindness-and-visual-impairment</a>.</li>
<li>W3C, “Web Content Accessibility Guidelines (WCAG) 2.2,” <a href="https://www.w3.org/TR/WCAG22/">https://www.w3.org/TR/WCAG22/</a>.</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Building EaseOut: A React Native Productivity App on Firebase and Gemini]]></title><description><![CDATA[A React Native Productivity App on Firebase and Gemini]]></description><link>https://vishalkumarojha.hashnode.dev/building-easeout-a-react-native-productivity-app-on-firebase-and-gemini</link><guid isPermaLink="true">https://vishalkumarojha.hashnode.dev/building-easeout-a-react-native-productivity-app-on-firebase-and-gemini</guid><category><![CDATA[React Native]]></category><category><![CDATA[Reactnative]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Vishal Ojha]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:13:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66fc3f0d2d6a5ab57529b5fe/dc0509f5-70b2-4e0e-ae88-f71fb95e0097.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The problem: productivity data is scattered</h2>
<p>Most personal productivity systems do not fail because people lack tools. They fail because the tools are scattered. A job application lives in a spreadsheet. A password lives in the browser. A study plan lives in a notes app. A project write-up lives in a document. A deadline lives in a calendar. The cost is not just switching apps; it is the mental tax of remembering where each piece of context belongs.</p>
<p>EaseOut is an attempt to collapse that surface area into one mobile app. It is a React Native application built with Expo, backed by Firebase, and augmented with Gemini for lightweight planning. The UI covers authentication, a dashboard, a password vault, job tracking, projects, profiles, a calendar, a study planner, custom workspaces, and AI-assisted goals.</p>
<p>The repository is also a useful case study because it is not a polished product with a hidden backend. Almost all of the application logic is visible in the client. That makes the architecture easy to inspect, but it also makes the security and reliability trade-offs easy to see.</p>
<p>This walkthrough is based on the repository at commit <code>357508d</code> and describes the code as written. It does not claim that the app is deployed, that every feature is complete, or that the current configuration is production-ready. Where something is partial or risky, I will say so directly.</p>
<hr />
<h2>What EaseOut actually does</h2>
<p>EaseOut is organized around a dashboard with a quick-access grid for eight features. The authenticated navigation stack also includes an account screen and a custom-spaces screen. The app has thirteen screen modules in total: three for authentication and ten for the signed-in experience.</p>
<p>The clearest way to understand the project is to separate what is implemented from what is scaffolded.</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Status</th>
<th>What the code actually does</th>
</tr>
</thead>
<tbody><tr>
<td>Email/password authentication</td>
<td>Implemented</td>
<td>Firebase Auth sign-up, sign-in, sign-out, profile creation</td>
</tr>
<tr>
<td>Session-based routing</td>
<td>Implemented</td>
<td>Auth state chooses between auth screens and the main stack</td>
</tr>
<tr>
<td>Dashboard</td>
<td>Implemented</td>
<td>Greeting, search, screen-time chart, quick access, priorities, AI plan</td>
</tr>
<tr>
<td>Global search</td>
<td>Implemented, client-side</td>
<td>Loads several Firestore collections and filters in memory</td>
</tr>
<tr>
<td>Password vault</td>
<td>Implemented, not encrypted</td>
<td>Add, reveal, copy, delete, and CSV import of passwords</td>
</tr>
<tr>
<td>CSV import</td>
<td>Partial and at risk</td>
<td>Naive parser, one batch, legacy file-system call</td>
</tr>
<tr>
<td>Job tracker</td>
<td>Implemented</td>
<td>Create, list, status update, delete, notes, free-text deadline</td>
</tr>
<tr>
<td>Calendar</td>
<td>Implemented</td>
<td>Date-based nested tasks, completion toggle, dots for dates</td>
</tr>
<tr>
<td>Study planner</td>
<td>Implemented; AI is simulated</td>
<td>Default four-step roadmap, progress toggles, canned “AI” explanation</td>
</tr>
<tr>
<td>AI daily plan</td>
<td>Implemented with caveats</td>
<td>Gemini REST call, daily Firestore cache, notification</td>
</tr>
<tr>
<td>AI goals</td>
<td>Implemented with caveats</td>
<td>Gemini prompt from a user-entered goal, local-only results</td>
</tr>
<tr>
<td>Projects</td>
<td>Implemented</td>
<td>Structured project records with problem, solution, stack, results</td>
</tr>
<tr>
<td>Profiles</td>
<td>Implemented</td>
<td>GitHub, LinkedIn, university, portfolio, and other links</td>
</tr>
<tr>
<td>Custom spaces</td>
<td>Implemented</td>
<td>User-created spaces with nested tasks</td>
</tr>
<tr>
<td>Theme</td>
<td>Implemented</td>
<td>Firestore-backed light/dark preference</td>
</tr>
<tr>
<td>Local notifications</td>
<td>Partial</td>
<td>Immediate notification after AI plan; scheduled helper is unused</td>
</tr>
<tr>
<td>Crash reporting</td>
<td>Implemented</td>
<td>Error boundary records to Crashlytics</td>
</tr>
<tr>
<td>Automated tests</td>
<td>Not present</td>
<td>No test or spec files in the repository</td>
</tr>
<tr>
<td>Release pipeline</td>
<td>Not present</td>
<td>CI checks only; Android release uses debug signing</td>
</tr>
</tbody></table>
<p>That table is intentionally conservative. A screen existing in the navigation stack does not mean every button on it works. Several support rows are visual only, the notification toggle in Account is local state, and the account statistics are placeholders.</p>
<p>The app name in <code>app.json</code> is <code>ease-out</code>, the Android package is <code>com.ease.out</code>, and the repository is still named <code>PassX-Easeout</code>. The README is the default Expo Snack template, and the <code>app.json</code> slug is a Snack UUID. Those details suggest the project began as a Snack experiment and was later moved into a bare Android workflow. The <code>components/AssetExample.js</code> file and the <code>assets/snack-icon.png</code> are leftover traces of that history.</p>
<hr />
<h2>The stack at a glance</h2>
<p>EaseOut is built on a deliberately mainstream React Native stack.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Choice</th>
</tr>
</thead>
<tbody><tr>
<td>Runtime</td>
<td>Expo SDK 54, React Native 0.81.5, React 19.1.0</td>
</tr>
<tr>
<td>Language</td>
<td>TypeScript 5.9 with <code>strict: true</code></td>
</tr>
<tr>
<td>Navigation</td>
<td>React Navigation 7 native stack</td>
</tr>
<tr>
<td>Authentication</td>
<td>React Native Firebase Auth 23.8.8</td>
</tr>
<tr>
<td>Database</td>
<td>Cloud Firestore</td>
</tr>
<tr>
<td>File storage</td>
<td>Firebase Storage</td>
</tr>
<tr>
<td>Crash reporting</td>
<td>Firebase Crashlytics</td>
</tr>
<tr>
<td>Local notifications</td>
<td>Notifee 9.1.8</td>
</tr>
<tr>
<td>AI</td>
<td>Gemini REST API, <code>gemini-1.5-flash</code> constant</td>
</tr>
<tr>
<td>Calendar UI</td>
<td><code>react-native-calendars</code></td>
</tr>
<tr>
<td>Charts</td>
<td><code>react-native-chart-kit</code></td>
</tr>
<tr>
<td>Native utilities</td>
<td>Expo Document Picker, Image Picker, Clipboard, Blur</td>
</tr>
</tbody></table>
<p>Firebase is the entire backend. There is no custom API server, no serverless function layer, and no repository-owned database schema migration system. The client talks directly to Firebase Auth, Firestore, Storage, and Crashlytics.</p>
<p>That choice buys a lot of speed. Authentication, real-time listeners, offline-capable Firestore clients, file uploads, and crash reporting all come from managed services. For a solo-built mobile app, it removes an entire backend deployment surface.</p>
<p>It also moves trust boundaries into the client. Every Firestore path, query, and Gemini request is initiated by the app. Firestore security rules become the only server-side authorization layer, and the Gemini key becomes a client-side secret if it is embedded in the bundle. Both topics matter later.</p>
<p>The app enables the React Native New Architecture and Hermes in <code>android/gradle.properties</code>. It tracks the <code>android/</code> directory because it uses the bare workflow, and there is no <code>ios/</code> directory even though the <code>package.json</code> includes an iOS script and <code>app.json</code> contains iOS settings. In practice, this is an Android-first native build.</p>
<p>A few dependency details matter. <code>@react-navigation/native-stack</code>, <code>react-native-calendars</code>, <code>react-native-chart-kit</code>, and <code>papaparse</code> use <code>*</code> ranges in <code>package.json</code>; the lockfile pins them today, but future updates can drift. <code>papaparse</code>, <code>expo-notifications</code>, <code>react-native-paper</code>, and <code>expo-status-bar</code> are declared but not imported directly. <code>react-native-svg</code> is a chart peer. And <code>expo-file-system</code> is imported by the password screen but is not declared as a direct dependency; it is resolved transitively. That last point becomes a real runtime concern in the CSV import flow.</p>
<hr />
<h2>Repository and architecture</h2>
<p>The repository is small enough to read in one sitting. The important parts are:</p>
<pre><code class="language-text">App.tsx
index.ts
src/
  components/
    BackHeader.tsx
    BottomNav.tsx
    CustomButton.tsx
    CustomInput.tsx
    dashboard/
      QuickAccessGrid.tsx
      ScreenTimeChart.tsx
  constants/
    colors.ts
  context/
    AuthContext.tsx
    ThemeContext.tsx
  screens/
    WelcomeScreen.tsx
    LoginScreen.tsx
    SignupScreen.tsx
    DashboardScreen.tsx
    AccountScreen.tsx
    features/
      PasswordManagerScreen.tsx
      JobTrackerScreen.tsx
      GoalsScreen.tsx
      StudyPlannerScreen.tsx
      CalendarScreen.tsx
      ProjectsScreen.tsx
      ProfilesScreen.tsx
      CustomSpacesScreen.tsx
  services/
    firebase.ts
    GeminiService.ts
    NotificationService.ts
  types/
    navigation.ts
android/
.github/workflows/ci.yml
</code></pre>
<p><code>App.tsx</code> is the composition root. It wraps the application in a Crashlytics error boundary, then an auth provider, then a theme provider, then the navigation container. The auth provider must sit above the theme provider because the theme preference is stored on the authenticated user's Firestore document.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgc3ViZ3JhcGggQ2xpZW50WyJSZWFjdCBOYXRpdmUgYXBwIC0gRXhwbyBTREsgNTQiXQogICAgVUlbIlNjcmVlbnMgYW5kIGNvbXBvbmVudHMiXQogICAgQ1RYWyJBdXRoQ29udGV4dCBhbmQgVGhlbWVDb250ZXh0Il0KICAgIE5BVlsiUmVhY3QgTmF2aWdhdGlvbiBuYXRpdmUgc3RhY2siXQogICAgU1ZDWyJTZXJ2aWNlczogRmlyZWJhc2UsIEdlbWluaSwgTm90aWZpY2F0aW9ucyJdCiAgZW5kCiAgc3ViZ3JhcGggRmlyZWJhc2VbIkZpcmViYXNlIHByb2plY3QiXQogICAgQVVUSFsiRmlyZWJhc2UgQXV0aCJdCiAgICBGU1siQ2xvdWQgRmlyZXN0b3JlIl0KICAgIFNUWyJDbG91ZCBTdG9yYWdlIl0KICAgIENSWyJDcmFzaGx5dGljcyJdCiAgZW5kCiAgR0VNWyJHZW1pbmkgQVBJPGJyLz5nZW5lcmF0ZUNvbnRlbnQiXQogIE5PVElGWyJOb3RpZmVlPGJyLz5sb2NhbCBub3RpZmljYXRpb25zIl0KCiAgVUkgLS0-IENUWAogIFVJIC0tPiBOQVYKICBVSSAtLT4gU1ZDCiAgQ1RYIC0tPiBBVVRICiAgQ1RYIC0tPiBGUwogIFNWQyAtLT4gRlMKICBTVkMgLS0-IFNUCiAgU1ZDIC0tPiBHRU0KICBTVkMgLS0-IE5PVElGCiAgVUkgLS0-IENSCg?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 1: EaseOut application architecture" /></p>
<p><em>Figure 1: The client-side architecture. Most application logic lives in React Native; Firebase supplies auth, data, storage, and crash reporting, while Gemini and Notifee are called from the client.</em></p>
<p>The layering is straightforward:</p>
<ul>
<li>Screens own local UI state and call Firestore directly.</li>
<li>Contexts own cross-screen state such as the current user and theme.</li>
<li>Services wrap the external integrations: Firebase initialization, Gemini calls, and Notifee notifications.</li>
<li>Firebase provides the durable state.</li>
</ul>
<p>There is no repository abstraction around Firestore. A screen typically imports <code>firestore()</code> and constructs a collection path itself. That keeps the code direct, but it also means query shapes and path conventions are spread across screens rather than centralized in a data layer.</p>
<p>The error boundary is class-based and calls <code>crashlytics().recordError(error)</code> plus <code>crashlytics().log()</code> with the component stack. It catches render and lifecycle errors in the React tree. It does not catch errors inside event handlers, promise rejections, or native crashes. That is normal for React error boundaries, but it is worth being precise about what “Crashlytics error boundary” covers.</p>
<p>The app requests notification permission in <code>RootNavigator</code> on mount. Because the navigator is mounted for both signed-out and signed-in states, the permission prompt can appear before the user has logged in. That is a product decision worth revisiting: asking for notifications before the user understands the value of the app tends to reduce opt-in rates.</p>
<hr />
<h2>Authentication and session routing</h2>
<p>Authentication uses Firebase Auth with email and password. The <code>AuthProvider</code> subscribes to <code>auth().onAuthStateChanged</code>, stores the Firebase user in React state, and loads a matching Firestore profile document.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgQVsiQXBwIHN0YXJ0Il0gLS0-IEJbIkF1dGhQcm92aWRlciBpbml0aWFsaXNlcyJdCiAgQiAtLT4gQ1sib25BdXRoU3RhdGVDaGFuZ2VkIl0KICBDIC0tPiBEeyJTaWduZWQtaW4gdXNlcj8ifQogIEQgLS0-fCJObyJ8IEVbIldlbGNvbWUsIExvZ2luIGFuZCBTaWdudXAgc2NyZWVucyJdCiAgRCAtLT58IlllcyJ8IEZbIkRhc2hib2FyZCBhbmQgZmVhdHVyZSBzY3JlZW5zIl0KICBFIC0tPnwibG9naW4gb3Igc2lnbnVwInwgQwogIEYgLS0-fCJsb2dvdXQifCBDCiAgQiAtLT4gR1siTG9hZGluZyBzcGlubmVyIHdoaWxlIGF1dGggcmVzb2x2ZXMiXQogIEcgLS0-IEMK?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 2: Authentication state flow" /></p>
<p><em>Figure 2: Authentication is driven by Firebase's auth state, not by manual navigation after login. The navigator switches stacks when the user changes.</em></p>
<p>The core of the provider is deliberately small:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const unsubscribe = auth().onAuthStateChanged(async (firebaseUser) =&gt; {
    setUser(firebaseUser);
    if (firebaseUser) {
      await fetchUserProfile(firebaseUser.uid);
    } else {
      setUserProfile(null);
    }
    setLoading(false);
  });
  return unsubscribe;
}, []);
</code></pre>
<p><code>fetchUserProfile</code> attaches a Firestore <code>onSnapshot</code> listener to <code>users/{uid}</code>. When the document exists, it copies the data into <code>userProfile</code>. That profile snapshot is what supplies the display name and photo URL throughout the app.</p>
<p>Sign-up does three things in sequence:</p>
<pre><code class="language-tsx">const cred = await auth().createUserWithEmailAndPassword(email.trim(), password);
await cred.user.updateProfile({ displayName: displayName.trim() });
await firestore().collection('users').doc(cred.user.uid).set({
  uid: cred.user.uid,
  email: email.trim(),
  displayName: displayName.trim(),
  createdAt: firestore.FieldValue.serverTimestamp(),
});
</code></pre>
<p>The profile write does not include <code>preferences.darkMode</code>. The theme provider reads that field with optional chaining and simply keeps the light theme when it is absent. That works, but it means the first login has no persisted theme preference until the user toggles the switch.</p>
<p>The navigator is conditional on <code>user</code>:</p>
<pre><code class="language-tsx">{user == null ? (
  &lt;&gt;
    &lt;Stack.Screen name="Welcome" component={WelcomeScreen} /&gt;
    &lt;Stack.Screen name="Login" component={LoginScreen} /&gt;
    &lt;Stack.Screen name="Signup" component={SignupScreen} /&gt;
  &lt;/&gt;
) : (
  &lt;&gt;
    &lt;Stack.Screen name="Dashboard" component={DashboardScreen} /&gt;
    {/* feature screens */}
    &lt;Stack.Screen name="Account" component={AccountScreen} /&gt;
  &lt;/&gt;
)}
</code></pre>
<p>This is the standard React Navigation auth-flow pattern: authentication changes the navigator's screen set, and navigation follows automatically. The login and signup screens do not call <code>navigation.replace</code> after success. That is a good design because it keeps one source of truth for session state.</p>
<p>The login screen maps Firebase error codes to friendlier messages and validates that email and password are present. Sign-up adds a confirmation password check and a minimum length of six characters. There is no email verification step, no password reset implementation, and no account deletion flow. The “Forgot Password?” text on the login screen is a <code>TouchableOpacity</code> with no <code>onPress</code> handler, so it is currently decorative.</p>
<p>The profile photo flow is more complete: <code>updateProfilePhoto</code> uploads to <code>profile_photos/{uid}.jpg</code>, gets the download URL, updates the Auth profile and Firestore document, and updates local state. It overwrites the same storage path each time and has no delete action.</p>
<p>One cleanup issue: <code>fetchUserProfile</code> creates a Firestore listener but does not return its unsubscribe function to the caller. The listener is not explicitly cleaned up when auth state changes, so repeated account switches can leave stale listeners attached. Keeping the unsubscribe in a ref, or restructuring the effect, would fix it.</p>
<hr />
<h2>The Firestore data model</h2>
<p>EaseOut uses a per-user document tree. Everything is nested under <code>users/{uid}</code>, which simplifies the mental model and makes it easy to reason about ownership.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgVVsidXNlcnMve3VpZH0iXQogIFUgLS0-IEZMQVRbIlByb2ZpbGUgYW5kIHJlY29yZHM8YnIvPmpvYnMgwrcgcHJvamVjdHMgwrcgcHJvZmlsZXMgwrcgcGFzc3dvcmRzIMK3IHN0dWR5VG9waWNzIl0KICBVIC0tPiBTUFsic3BhY2VzL3tzcGFjZUlkfSJdCiAgU1AgLS0-IFNQVFsidGFza3Mve3Rhc2tJZH0iXQogIFUgLS0-IENBTFsiY2FsZW5kYXIve1lZWVktTU0tRER9Il0KICBDQUwgLS0-IENUWyJ0YXNrcy97dGFza0lkfSJdCiAgVSAtLT4gREFJTFlbIkRhaWx5IGRvY3VtZW50czxici8-YWlQbGFuL3tkYXRlfSDCtyBzY3JlZW5UaW1lL3tkYXRlfSJdCg?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 3: Firestore data hierarchy" /></p>
<p><em>Figure 3: The top-level user document branches into flat record collections, nested space tasks, date-keyed calendar tasks, and daily AI/screen-time documents.</em></p>
<p>The concrete paths are:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Purpose</th>
<th>Important fields</th>
</tr>
</thead>
<tbody><tr>
<td><code>users/{uid}</code></td>
<td>User profile and preferences</td>
<td><code>uid</code>, <code>email</code>, <code>displayName</code>, <code>photoURL</code>, <code>preferences.darkMode</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/jobs/{jobId}</code></td>
<td>Job applications</td>
<td><code>companyName</code>, <code>role</code>, <code>link</code>, <code>deadline</code>, <code>status</code>, <code>notes</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/projects/{projectId}</code></td>
<td>Project portfolio</td>
<td><code>title</code>, <code>description</code>, <code>problemStatement</code>, <code>solution</code>, <code>techStack</code>, <code>results</code>, <code>collaboration</code>, <code>link</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/profiles/{profileId}</code></td>
<td>External profiles</td>
<td><code>platformName</code>, <code>accountName</code>, <code>email</code>, <code>link</code>, <code>type</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/passwords/{passwordId}</code></td>
<td>Password vault</td>
<td><code>website</code>, <code>username</code>, <code>password</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/studyTopics/{topicId}</code></td>
<td>Study goals</td>
<td><code>title</code>, <code>category</code>, <code>roadmap</code>, <code>completedSteps</code>, <code>totalSteps</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/spaces/{spaceId}</code></td>
<td>Custom workspaces</td>
<td><code>name</code>, <code>color</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/spaces/{spaceId}/tasks/{taskId}</code></td>
<td>Workspace tasks</td>
<td><code>title</code>, <code>deadline</code>, <code>isCompleted</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/calendar/{YYYY-MM-DD}/tasks/{taskId}</code></td>
<td>Calendar tasks</td>
<td><code>title</code>, <code>type</code>, <code>time</code>, <code>isCompleted</code>, <code>createdAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/aiPlan/{YYYY-MM-DD}</code></td>
<td>Cached daily plan</td>
<td><code>items[]</code>, <code>generatedAt</code></td>
</tr>
<tr>
<td><code>users/{uid}/screenTime/{YYYY-MM-DD}</code></td>
<td>Daily usage record</td>
<td><code>date</code>, <code>hours</code></td>
</tr>
</tbody></table>
<p>Two conventions stand out. First, calendar tasks are keyed by date at the document level, so a query for one day is a direct subcollection read. Second, AI plans and screen-time records use <code>YYYY-MM-DD</code> document IDs. This makes daily caching and lookup trivial.</p>
<p>The trade-off is that all reads and writes are client-driven. There is no backend validation layer for field shapes, allowed status values, or ownership. TypeScript describes the intended shape, but Firestore stores whatever the client sends. A buggy or malicious client can write arbitrary fields to its own subtree if the rules allow it.</p>
<p>The repository does not contain Firestore security rules, which means the access-control story cannot be verified from source. It also has no index configuration. The current queries are simple — <code>orderBy('createdAt', 'desc')</code>, equality filters, and an <code>in</code> query on dates — but a growing app will need composite indexes.</p>
<p>The date-key convention has a timezone trap. Most date keys are produced with:</p>
<pre><code class="language-ts">new Date().toISOString().split('T')[0]
</code></pre>
<p><code>toISOString()</code> returns a UTC timestamp. Around local midnight, the UTC date can be the previous or next calendar day. A user in India at 1:00 AM local time may write a task under the previous UTC date, while the calendar UI is showing the new local date. The calendar screen uses the selected day's <code>dateString</code> from <code>react-native-calendars</code>, which is local-calendar oriented, but “today” defaults and screen-time writes use UTC. A consistent local-date helper would remove a class of off-by-one bugs.</p>
<hr />
<h2>How a feature writes and reads data</h2>
<p>The job tracker is a good example of the app's data flow because it is simple and complete. The screen attaches a real-time listener to the jobs collection, sorted by creation time:</p>
<pre><code class="language-tsx">const unsub = firestore()
  .collection('users').doc(user.uid).collection('jobs')
  .orderBy('createdAt', 'desc')
  .onSnapshot((snap) =&gt; {
    setJobs(snap.docs.map((d) =&gt; ({ id: d.id, ...d.data() } as JobApplication)));
    setLoading(false);
  }, () =&gt; setLoading(false));
return unsub;
</code></pre>
<p>Creating a job writes a new document and immediately closes the modal:</p>
<pre><code class="language-tsx">await firestore()
  .collection('users').doc(user!.uid).collection('jobs')
  .add({
    companyName: newCompany,
    role: newRole,
    link: newLink,
    deadline: newDeadline || 'No Deadline',
    status: JobStatus.APPLIED,
    notes: newNotes,
    createdAt: firestore.FieldValue.serverTimestamp(),
  });
</code></pre>
<p>The list updates because the <code>onSnapshot</code> listener fires after the write. There is no optimistic local insert. That means the UI waits for the server round trip and the listener callback, which is usually fast but can feel slower on poor networks. The benefit is that the screen never drifts from Firestore's state.</p>
<p>Status updates are equally direct:</p>
<pre><code class="language-tsx">await firestore()
  .collection('users').doc(user!.uid).collection('jobs')
  .doc(id).update({ status });
</code></pre>
<p>The job statuses are a TypeScript enum: <code>Applied</code>, <code>No Response</code>, <code>Interview</code>, <code>Rejected</code>, and <code>Offer</code>. The UI renders status chips and color-codes them. The deadline is free text (<code>"April 1st"</code>, <code>"No Deadline"</code>), not a timestamp. That keeps data entry flexible but makes reminders and sorting unreliable. The <code>link</code> field is stored but not rendered or opened, and the notes are displayed in the details modal.</p>
<p>The same pattern repeats across projects, profiles, passwords, study topics, calendar tasks, and spaces: a screen owns its path, subscribes with <code>onSnapshot</code>, writes with <code>add</code> or <code>update</code>, and deletes with <code>delete</code>. The consistency makes the code easy to follow, but there is no shared validation or error-handling policy. Some writes show alerts; others catch and ignore. That is the main maintainability cost of skipping a data layer.</p>
<p>Calendar tasks add one more wrinkle because they are nested under a date document:</p>
<pre><code class="language-tsx">const tasksRef = () =&gt;
  firestore()
    .collection('users').doc(user!.uid)
    .collection('calendar').doc(selectedDate)
    .collection('tasks');
</code></pre>
<p>The screen listens to the selected date's tasks and separately loads every date document to draw dots on the calendar. The second effect depends on <code>tasks</code>, so every task change triggers a fresh read of the entire calendar collection. For a small personal dataset that is acceptable; for a long history, it is wasteful. A single date-range query or a maintained “dates with tasks” summary document would scale better.</p>
<hr />
<h2>The AI daily plan pipeline</h2>
<p>The most interesting part of EaseOut is the AI daily plan. It is also the part with the most caveats.</p>
<p>The intended flow is simple: when the dashboard mounts, read today's cached plan from Firestore. If a plan exists, render it. If not, gather a small amount of context from jobs and study topics, ask Gemini for a short action list, cache the result, and send a local notification.</p>
<p><img src="https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgYXV0b251bWJlcgogIHBhcnRpY2lwYW50IEQgYXMgRGFzaGJvYXJkCiAgcGFydGljaXBhbnQgRiBhcyBGaXJlc3RvcmUKICBwYXJ0aWNpcGFudCBHIGFzIEdlbWluaVNlcnZpY2UKICBwYXJ0aWNpcGFudCBBIGFzIEdlbWluaSBBUEkKICBwYXJ0aWNpcGFudCBOIGFzIE5vdGlmZWUKCiAgRC0-PkY6IFJlYWQgYWlQbGFuL3t0b2RheX0KICBhbHQgQ2FjaGVkIHBsYW4gZXhpc3RzCiAgICBGLS0-PkQ6IGl0ZW1zIGFycmF5CiAgZWxzZSBObyBwbGFuIGZvciB0b2RheQogICAgRC0-PkY6IFF1ZXJ5IGpvYnMgYW5kIHN0dWR5VG9waWNzCiAgICBGLS0-PkQ6IGNvbnRleHQgZGF0YQogICAgRC0-Pkc6IGdlbmVyYXRlRGFpbHlQbGFuKGFwaUtleSwgY29udGV4dCkKICAgIEctPj5BOiBQT1NUIGdlbmVyYXRlQ29udGVudAogICAgQS0tPj5HOiByZXNwb25zZSB0ZXh0CiAgICBHLS0-PkQ6IHBhcnNlZCBpdGVtcwogICAgRC0-PkY6IFdyaXRlIGFpUGxhbi97dG9kYXl9CiAgICBELT4-Tjogc2VuZEltbWVkaWF0ZU5vdGlmaWNhdGlvbgogIGVuZAogIEQtLT4-RDogUmVuZGVyIGNoZWNrbGlzdAo?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 4: AI daily plan sequence" /></p>
<p><em>Figure 4: The dashboard checks the daily cache first. On a cache miss, it builds context, calls Gemini, writes the parsed plan back to Firestore, and optionally sends a notification.</em></p>
<p>The prompt builder is explicit about the output format:</p>
<pre><code class="language-ts">return `You are a productivity assistant for ${ctx.userName}. Today is ${today}.

Here is their current status:

**Job Applications:**
${jobList}

**Today's Scheduled Tasks:**
${calList}

**Study Goals:**
${studyList}

Based on this data, create a concise, actionable plan for today.
Return EXACTLY 4-5 short action items (max 12 words each).
Format each item on its own line, starting with a dash (-).
Focus on what's most urgent. Be specific, not generic.
Do NOT include any other text, headers, or explanation.`;
</code></pre>
<p>The API call is a direct REST request:</p>
<pre><code class="language-ts">const response = await fetch(`${GEMINI_API_BASE}?key=${apiKey}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    contents: [{ parts: [{ text: prompt }] }],
    generationConfig: { temperature: 0.5, maxOutputTokens: 300 },
  }),
});
</code></pre>
<p>The service extracts <code>candidates[0].content.parts[0].text</code>, splits it into lines, keeps lines that start with <code>-</code>, strips the dash, and maps the result to <code>DailyPlanItem</code> objects. This is intentionally minimal. It does not use a schema, a response parser, or a retry policy. If Gemini adds a preamble or wraps the list in Markdown, the parser silently drops those lines.</p>
<p>The dashboard caching logic is the real orchestration layer:</p>
<pre><code class="language-tsx">const todayKey = new Date().toISOString().split('T')[0];
const planRef = firestore()
  .collection('users').doc(user.uid)
  .collection('aiPlan').doc(todayKey);

planRef.get().then(async (snap) =&gt; {
  if (snap.exists &amp;&amp; snap.data()?.items?.length &gt; 0) {
    setAiPlan(snap.data()!.items as DailyPlanItem[]);
    return;
  }

  const [jobsSnap, studySnap] = await Promise.all([
    firestore().collection('users').doc(user.uid).collection('jobs')
      .where('status', 'in', ['Applied', 'Interview']).limit(3).get(),
    firestore().collection('users').doc(user.uid).collection('studyTopics').limit(3).get(),
  ]);

  const items = await generateDailyPlan(GEMINI_API_KEY, {
    userName: name,
    jobs,
    calendarTasks: [],
    studyTopics,
  });

  await planRef.set({
    items,
    generatedAt: firestore.FieldValue.serverTimestamp(),
  });
  setAiPlan(items);
});
</code></pre>
<p>Four observations matter.</p>
<p>First, <code>calendarTasks</code> is hard-coded to <code>[]</code>. The prompt has a section for today's scheduled tasks, but the dashboard never supplies them. That is a missed opportunity rather than a prompt bug.</p>
<p>Second, the catch block is empty:</p>
<pre><code class="language-tsx">} catch { /* silently skip if key not set */ }
</code></pre>
<p>If the key is missing, the model name is wrong, the network is down, or Gemini returns an error, the user sees no plan and no explanation. Silent failure is convenient during development and confusing in production. At minimum, the UI should distinguish “no plan yet,” “generating,” and “generation failed.”</p>
<p>Third, the model constant is <code>gemini-1.5-flash</code>:</p>
<pre><code class="language-ts">const GEMINI_API_BASE =
  'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
</code></pre>
<p>Google lists Gemini 1.5 Flash as shut down on September 29, 2025, so this constant needs a supported replacement. The repository's placeholder key, <code>AIzaSyDummyReplaceMe</code>, is not a real key either.</p>
<p>Fourth, the API key is a client-side constant. Even if it is moved into an environment variable at build time, it ends up in the app bundle and can be extracted. For a published app, the Gemini call should go through a backend or serverless function that authenticates the user, applies quotas, and keeps the provider key off the device.</p>
<p>The Goals screen reuses <code>generateDailyPlan</code> with a different context. It sends the user's goal as a single study topic and renders the resulting items as local checkboxes. The results are not persisted to Firestore, so leaving the screen loses the generated plan. The screen also uses the same placeholder key and model constant.</p>
<p>The Study Planner's “Ask AI to Explain This Topic” button does not call Gemini at all. It opens a modal, waits 1.2 seconds, and displays a canned three-point explanation:</p>
<pre><code class="language-tsx">setTimeout(() =&gt; {
  setExplanationText(
    `Here is a simplified explanation of "${title}":\n\n` +
    `1. Core Concept — Understand the fundamental building blocks.\n` +
    `2. Why it matters — Critical for scalability and performance.\n` +
    `3. Key Takeaway — Master this to build robust, real-world systems.`
  );
}, 1200);
</code></pre>
<p>That is a prototype affordance, not an AI feature. It should either be wired to a real model or labeled as a placeholder in the UI.</p>
<p>The AI plan is cached per user per day, which is the right instinct. AI calls are slow and potentially expensive, and a daily plan does not need to be regenerated on every dashboard mount. The cache key is UTC-based, so the same timezone caveat applies. The plan is also stored as a single array field. Toggling one item rewrites the entire array:</p>
<pre><code class="language-tsx">const updated = aiPlan.map((item) =&gt;
  item.id === id ? { ...item, isCompleted: !item.isCompleted } : item
);
await firestore()
  .collection('users').doc(user.uid)
  .collection('aiPlan').doc(todayKey)
  .update({ items: updated });
</code></pre>
<p>For a four-to-five item list, this is fine. If two devices toggle items at the same time, one write can overwrite the other. An array of item documents or a transaction would avoid that.</p>
<hr />
<h2>The password vault and CSV import</h2>
<p>The password vault is the feature with the highest security stakes and the largest gap between intent and implementation.</p>
<p>On the surface, it behaves like a simple credential manager. Entries have a website, username, and password. Passwords are masked by default and can be revealed per entry. The copy button writes the password to the system clipboard. Entries can be added and deleted. The list is a real-time Firestore subscription ordered by creation time.</p>
<p>The storage model is the problem: passwords are stored as plaintext strings in Firestore.</p>
<pre><code class="language-tsx">await ref().add({
  website: newWebsite.trim(),
  username: newUsername.trim(),
  password: newPassword,
  createdAt: firestore.FieldValue.serverTimestamp(),
});
</code></pre>
<p>There is no encryption, no hashing, no field-level cryptography, and no client-side vault key. Firestore transport encryption protects data in transit, and Firebase encrypts data at rest, but neither protects against an authorized reader, a compromised account, a misconfigured security rule, or an administrator with access to the project. For a real password manager, plaintext storage is a release blocker.</p>
<p>The CSV import flow is also fragile.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgQVsiVGFwIENTViBpbXBvcnQiXSAtLT4gQlsiZXhwby1kb2N1bWVudC1waWNrZXIiXQogIEIgLS0-IEN7IkNhbmNlbGVkPyJ9CiAgQyAtLT58IlllcyJ8IFpbIlJldHVybiB0byB2YXVsdCJdCiAgQyAtLT58Ik5vInwgRFsiUmVhZCBmaWxlIGFzIHN0cmluZyJdCiAgRCAtLT4gRVsicGFyc2VDU1Y6IHNwbGl0IGxpbmVzIGFuZCBjb21tYXMiXQogIEUgLS0-IEZ7IkFueSB2YWxpZCBlbnRyaWVzPyJ9CiAgRiAtLT58Ik5vInwgR1siU2hvdyBwYXJzZSBlcnJvciBhbGVydCJdCiAgRiAtLT58IlllcyJ8IEhbIkNyZWF0ZSBGaXJlc3RvcmUgYmF0Y2giXQogIEggLS0-IElbIkNvbW1pdCBhbGwgd3JpdGVzIl0KICBJIC0tPiBKWyJTaG93IGltcG9ydCBjb21wbGV0ZSBhbGVydCJdCg?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 5: CSV import flow" /></p>
<p><em>Figure 5: The import path reads a file, parses it with a simple splitter, writes every entry in one Firestore batch, and reports success. There is no chunking, schema validation, or encryption step.</em></p>
<p>The file read uses:</p>
<pre><code class="language-tsx">import * as FileSystem from 'expo-file-system';
const content = await FileSystem.readAsStringAsync(asset.uri);
</code></pre>
<p>Expo SDK 54 moved the legacy file-system API to <code>expo-file-system/legacy</code>. The default <code>expo-file-system</code> entry point exposes the new object-oriented <code>File</code> and <code>Directory</code> APIs. Importing the default module and calling <code>readAsStringAsync</code> is therefore a runtime risk on SDK 54. The import also is not declared in <code>package.json</code>; it is resolved transitively today. Both should be fixed together.</p>
<p>The parser is a hand-rolled comma split:</p>
<pre><code class="language-ts">const lines = csv.split('\n').map((l) =&gt; l.trim()).filter(Boolean);
const headers = lines[0].toLowerCase().split(',');
// ...
const cols = line.split(',');
</code></pre>
<p>This breaks on quoted fields, embedded commas, escaped quotes, and password values that contain commas. The project already declares <code>papaparse</code>, which handles these cases, but the dependency is unused. Replacing the parser with Papa Parse would remove a large class of import failures.</p>
<p>The writes are batched but not chunked:</p>
<pre><code class="language-ts">const batch = firestore().batch();
parsed.forEach((entry) =&gt; {
  const docRef = ref().doc();
  batch.set(docRef, {
    ...entry,
    createdAt: firestore.FieldValue.serverTimestamp(),
  });
});
await batch.commit();
</code></pre>
<p>Firestore batches are limited to 500 writes. A CSV with more than 500 valid entries will fail the entire commit. The app reports a generic “Could not read or parse the CSV file” error, which obscures the real cause. Chunking into batches of 500 or fewer, or using a server-side import, would make the feature robust.</p>
<p>There is also no duplicate detection. Importing the same file twice creates duplicate entries. There is no edit flow for an existing password, only add and delete. The clipboard copy is convenient but leaves the password in the system clipboard, where other apps may be able to read it.</p>
<p>A credible vault would need end-to-end encryption with a user-derived key, a server-side or App Check-protected write path, strict Firestore rules, a migration plan for existing plaintext records, and import validation. Until then, this feature should be treated as a prototype, not a place to store real credentials.</p>
<hr />
<h2>Screen time, calendar tasks, and notifications</h2>
<p>The dashboard's “Screen Time Analytics” chart looks like device usage data, but it is not. It measures how long the <code>ScreenTimeChart</code> component stays mounted and writes the elapsed hours to a Firestore document when the component unmounts.</p>
<pre><code class="language-tsx">let _sessionStartMs: number | null = null;

useEffect(() =&gt; {
  _sessionStartMs = Date.now();

  return () =&gt; {
    if (_sessionStartMs &amp;&amp; user) {
      const elapsedHours = (Date.now() - _sessionStartMs) / 3600000;
      screenTimeRef().doc(todayKey).get().then((doc) =&gt; {
        const existing = doc.data()?.hours || 0;
        screenTimeRef().doc(todayKey).set({
          date: todayKey,
          hours: parseFloat((existing + elapsedHours).toFixed(2)),
        });
      });
    }
  };
}, [user]);
</code></pre>
<p>This is an app-session proxy, not Android Digital Wellbeing or iOS Screen Time. It does not measure other apps, unlocked-phone time, or time when the component is unmounted. A force-kill can also skip the cleanup, losing the session. The manual “Edit Today” chips are useful for a demo, but the “Screen Time Analytics” label oversells the data. The module-level <code>_sessionStartMs</code> is also shared across component instances; a ref or dedicated session service would be safer.</p>
<p>Calendar tasks are stored under <code>calendar/{date}/tasks</code>. The screen listens to the selected date and draws dots for dates that have documents. Tasks have a title, a type (<code>job</code>, <code>study</code>, or <code>personal</code>), a free-text time, and a completion flag. The UI is clean and the nested model is intuitive. The main issues are the repeated full-calendar read and the UTC date key described earlier.</p>
<p>Notifications use Notifee. The service creates a high-importance Android channel called <code>easeout_reminders</code>, supports immediate notifications, and includes a trigger-based helper for deadline reminders:</p>
<pre><code class="language-tsx">export async function scheduleDeadlineNotification(
  notifId: string,
  title: string,
  body: string,
  deadlineDate: Date,
  hoursBefore = 24,
): Promise&lt;void&gt; {
  await ensureChannel();
  const triggerDate = new Date(
    deadlineDate.getTime() - hoursBefore * 60 * 60 * 1000
  );
  if (triggerDate.getTime() &lt;= Date.now()) return;

  const trigger: TimestampTrigger = {
    type: TriggerType.TIMESTAMP,
    timestamp: triggerDate.getTime(),
  };

  await notifee.createTriggerNotification({ /* ... */ }, trigger);
}
</code></pre>
<p>The helper is well-formed, but nothing calls it. The only notification sent by the app is an immediate summary after a new AI plan is generated:</p>
<pre><code class="language-tsx">if (items.length &gt; 0) {
  sendImmediateNotification(
    `Your AI plan for today, ${name}!`,
    items.slice(0, 2).map((i) =&gt; `• ${i.text}`).join('\n'),
  );
}
</code></pre>
<p>The practical consequence is that deadline reminders are not implemented, even though the code suggests they are. Job deadlines are free text, so the included <code>parseDeadlineToDate</code> helper would also need a reliable date source before scheduling could work.</p>
<p>The Account screen's notification switch is local React state initialized to <code>true</code>. It does not request permission, does not persist a preference, and does not enable or disable Notifee. The app requests permission once at navigator mount, and the switch is disconnected from that state.</p>
<p>For Android, exact-time notifications can also require exact-alarm permissions depending on the target SDK and use case. The repository does not include a manifest-level exact-alarm request or a user-facing rationale. That is another reason to treat the notification layer as partial.</p>
<hr />
<h2>CI, tests, and the release gap</h2>
<p>The repository includes a GitHub Actions workflow named “EaseOut CI.” It runs on pushes and pull requests to <code>main</code>.</p>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgQVsiUHVzaCBvciBwdWxsIHJlcXVlc3QgdG8gbWFpbiJdIC0tPiBCWyJDaGVja291dCJdCiAgQiAtLT4gQ1siTm9kZSAyMCB3aXRoIG5wbSBjYWNoZSJdCiAgQyAtLT4gRFsibnBtIGNpIC0tbGVnYWN5LXBlZXItZGVwcyJdCiAgRCAtLT4gRVsibnB4IHRzYyAtLW5vRW1pdCJdCiAgRSAtLT4gRlsiZ3JlcCBzZWNyZXQgc2NhbiJdCiAgRiAtLT4gR1sibnBtIGF1ZGl0IC0tYXVkaXQtbGV2ZWw9aGlnaCJdCiAgRyAtLT4gSHsiQWxsIHN0ZXBzIHBhc3M_In0KICBIIC0tPnwiWWVzInwgSVsiQ0kgZ3JlZW4iXQogIEggLS0-fCJObyJ8IEpbIkNJIGZhaWxzIl0K?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 6: CI workflow" /></p>
<p><em>Figure 6: The CI workflow installs dependencies, type-checks, scans for secrets, and audits dependencies. It does not run tests, lint, or build an Android artifact.</em></p>
<p>The workflow is short and readable:</p>
<pre><code class="language-yaml">- run: npm ci --legacy-peer-deps
- run: npx tsc --noEmit
- name: Check for hardcoded secrets
  run: |
    echo "Scanning for potential hardcoded secrets..."
    if grep -rn "AIzaSy\|sk-[a-zA-Z0-9]\|PRIVATE_KEY" \
      --include="*.ts" --include="*.tsx" --include="*.js" src/; then
      echo "WARNING: Potential secrets found in source code!"
      exit 1
    fi
    echo "No secrets found."
- run: npm audit --audit-level=high
</code></pre>
<p>The job is named “Lint &amp; Type Check,” but there is no lint step and no ESLint configuration. The type check is real: <code>tsc --noEmit</code> uses the Expo base config with <code>strict: true</code>, which is a meaningful guard against type errors assuming dependencies install cleanly.</p>
<p>The secret scan has a concrete false positive. The placeholder <code>AIzaSyDummyReplaceMe</code> appears in both <code>DashboardScreen.tsx</code> and <code>GoalsScreen.tsx</code>. The grep pattern matches <code>AIzaSy</code>, so the step prints both matches and exits with status 1. The scan is doing its job — it is detecting a hardcoded key-shaped string — but the current code guarantees a red build. The right fix is not to weaken the scan; it is to remove the placeholder from source and load the key through a secure configuration path.</p>
<p>There are no test files in the repository. No Jest configuration, no React Native Testing Library setup, no Detox or Maestro flows, and no test script in <code>package.json</code>. The CI pipeline verifies that the TypeScript compiles and that dependencies do not have known high-severity vulnerabilities. It does not verify that login works, that a job can be created, that a calendar task persists, or that a CSV import handles a quoted comma.</p>
<p>That is the biggest quality gap in the project. For a small prototype, type-checking is a reasonable first gate. For a password vault and a data-heavy productivity app, it is not enough. The highest-value tests would be:</p>
<ul>
<li>Auth provider state transitions and sign-out cleanup.</li>
<li>Firestore path construction for each feature.</li>
<li>CSV parsing with quoted fields, CRLF, and embedded commas.</li>
<li>Batch chunking above 500 writes.</li>
<li>AI response parsing with malformed and well-formed responses.</li>
<li>Calendar date-key behavior around local midnight.</li>
<li>A smoke test that navigates through each feature screen with a mocked Firebase layer.</li>
</ul>
<p>The Android release configuration is also not production-ready. <code>android/app/build.gradle</code> uses the debug signing config for the release build:</p>
<pre><code class="language-gradle">buildTypes {
  debug {
    signingConfig signingConfigs.debug
  }
  release {
    // Caution! In production, you need to generate your own keystore file.
    signingConfig signingConfigs.debug
    // ...
  }
}
</code></pre>
<p>That is fine for local testing and unacceptable for a store release. There is no EAS configuration, no release workflow, no versioning automation, and no store metadata. The CI workflow does not build an APK or AAB. The repository is a source snapshot, not a release pipeline.</p>
<hr />
<h2>Running the project locally</h2>
<p>Because EaseOut uses native Firebase and Notifee modules, Expo Go is not sufficient. You need a development build or a local native build.</p>
<pre><code class="language-bash">git clone https://github.com/vishalkumarojha/PassX-Easeout.git
cd PassX-Easeout
npm ci --legacy-peer-deps
npx expo run:android
</code></pre>
<p>You will also need Node.js 20, JDK 17 and Android Studio, an emulator or USB device, a Firebase project with Auth, Firestore, Storage, and Crashlytics enabled, your own <code>google-services.json</code>, and a valid Gemini key with a supported model name.</p>
<p>The <code>ios</code> and <code>web</code> scripts are unlikely to work as-is. There is no <code>ios/</code> directory, and the app depends on native Firebase and Notifee modules that are not configured for web. The Android path is the supported one.</p>
<p>There is no <code>.env</code> file, no <code>app.config.ts</code>, and no runtime configuration layer. The Gemini key is a source constant; Firebase configuration is expected from <code>google-services.json</code>, which is intentionally ignored. The <code>.gitignore</code> also excludes <code>.env</code>, <code>.env.local</code>, <code>.env.production</code>, keystores, and provisioning profiles.</p>
<p>The Firebase console work is not optional: create the Android app with package <code>com.ease.out</code>, enable email/password auth, create Firestore and Storage, add the Crashlytics plugin, and download the config file. The README does not explain any of this. It is the default Snack README, which tells the reader to open <code>App.js</code> — a file that does not exist here. Replacing it would be one of the highest-leverage documentation improvements.</p>
<hr />
<h2>Security and privacy review</h2>
<p>The security posture is the most important part of this codebase to discuss honestly.</p>
<h3>Plaintext passwords</h3>
<p>The password vault stores credentials as plaintext Firestore fields. Anyone who can read the user's Firestore document can read every password. That includes a compromised account, a misconfigured rule, a project administrator, or a malicious client that can authenticate as the user. This is the single largest risk in the app.</p>
<h3>Client-side AI key</h3>
<p>The Gemini key is embedded in the client. Environment variables in a React Native build are not secrets; they are build-time strings that can be extracted from the bundle. The code also sends the key as a query parameter (<code>?key=...</code>), which is more likely to appear in logs and proxy traces than an <code>x-goog-api-key</code> header. A backend proxy is the correct boundary.</p>
<h3>Missing security rules</h3>
<p>The repository does not include Firestore or Storage rules. The per-user path structure is a good foundation, but it only helps if the rules enforce it. The minimum Firestore rule is a user-scoped match on <code>users/{uid}</code> with <code>request.auth.uid == uid</code>, applied recursively to subcollections. Storage needs an equivalent rule for <code>profile_photos/{uid}.jpg</code>. Without those rules, the authorization model depends entirely on the deployed project configuration, which is not visible here.</p>
<h3>Client-side search over sensitive data</h3>
<p>The dashboard search loads entire collections into memory and filters them in JavaScript. That includes the passwords collection, although the search only matches website and username, not the password value. The query still transfers password documents to the client and holds them in memory. For a vault with many entries, that is both a performance and a privacy problem. Server-side search with indexed, non-secret fields would be safer.</p>
<h3>Broad Android storage permissions</h3>
<p>The Android manifest requests <code>READ_EXTERNAL_STORAGE</code>, <code>WRITE_EXTERNAL_STORAGE</code>, and <code>SYSTEM_ALERT_WINDOW</code>. The app uses the system document and image pickers, which do not require broad storage access on modern Android, and a productivity app has no clear reason to draw over other apps. For an app that handles passwords and personal profiles, requesting more access than necessary is a privacy and review risk. The manifest should be reduced to the permissions the app actually needs.</p>
<p>The auth and account screens also load remote images from Freepik and pravatar. Those are third-party requests and need a license and privacy review before launch.</p>
<h3>Insecure release signing</h3>
<p>The release build uses the debug keystore. A production release signed with a debug key is not acceptable for distribution. It also makes it easy to accidentally ship a build that cannot be upgraded cleanly.</p>
<h3>Positive signals</h3>
<p>The project does several things well:</p>
<ul>
<li>Firebase Auth is the source of truth for session state.</li>
<li>Each user's data is namespaced under their UID.</li>
<li>Server timestamps are used for creation times.</li>
<li>The <code>.gitignore</code> excludes Firebase config, environment files, and keystores.</li>
<li>The CI workflow attempts a secret scan.</li>
<li>The error boundary reports render errors to Crashlytics.</li>
<li>The app does not log passwords or AI keys intentionally.</li>
</ul>
<p>Those are good habits. The gap is that the security-sensitive features need stronger guarantees than good habits.</p>
<hr />
<h2>Trade-offs and lessons</h2>
<p>EaseOut shows the trade-offs of a client-heavy Firebase app.</p>
<p>A custom backend would add middleware, API routes, validation, deployment, and monitoring. Firebase avoids all of that and gives the app auth, real-time data, storage, and crash reporting on day one. For a solo developer validating an idea, that is rational.</p>
<p>The cost is that the trust boundary moves into the client. Authorization depends on Firestore rules, secrets risk ending up in the bundle, and data shapes are validated only by TypeScript at compile time. The architecture is fast, but it requires discipline in the areas where prototypes are usually weakest.</p>
<p>A small data layer would help. The screens each build paths and listeners independently; centralizing path builders, date keys, query limits, error normalization, batch chunking, and defaults would make correctness fixes apply everywhere. Tests matter too: the app has many CRUD paths, nested collections, date keys, batch writes, and an external API. Type-checking catches shape errors, not behavior errors.</p>
<hr />
<h2>What I would build next</h2>
<p>I would sequence the work by risk, not feature count.</p>
<h3>1. Fix the password vault or remove it</h3>
<p>Do not advertise the vault as secure until it is encrypted. Build end-to-end encryption with a user-derived key, integrate a dedicated secrets manager, or remove the vault from the production build. Plaintext Firestore storage should not ship.</p>
<h3>2. Move Gemini behind a backend</h3>
<p>A small serverless function can authenticate the user, enforce quotas, build the prompt, call Gemini with a server-side key, and return only the parsed plan. That removes the key from the client and gives the app a place for rate limiting, logging, and model fallback.</p>
<h3>3. Update the model and failure states</h3>
<p>Replace <code>gemini-1.5-flash</code> with a supported model. Add explicit loading, empty, error, and retry states. Include calendar tasks in the context. Store the generated plan with its context so the user can understand why an item appeared.</p>
<h3>4. Replace the CSV parser and chunk writes</h3>
<p>Use Papa Parse, validate rows, deduplicate entries, and split writes into batches of 500 or fewer. Fix the <code>expo-file-system</code> import for SDK 54.</p>
<h3>5. Add Firestore and Storage rules</h3>
<p>Restrict every user path to the authenticated UID, validate required fields, and prevent clients from writing server-managed fields. Add emulator tests and include the rules in CI.</p>
<h3>6. Make CI meaningful</h3>
<p>Fix the placeholder-key false positive, add linting or rename the job, add unit tests, and add a release build. The current workflow is a start, not a production quality gate.</p>
<h3>7. Wire notifications end to end</h3>
<p>Use structured deadlines, schedule reminders with Notifee, persist the preference, handle permission denial and exact-alarm constraints, and cancel reminders when a job is deleted or completed.</p>
<h3>8. Replace the README and improve the data layer</h3>
<p>Document Firebase setup, <code>google-services.json</code>, the Gemini key strategy, the Android build, and current limitations. Centralize paths, date helpers, query limits, and error handling; add pagination and replace full-collection search with indexed queries.</p>
<h3>9. Add tests around risky paths</h3>
<p>Start with auth state, Firestore path construction, CSV parsing, batch chunking, AI response parsing, and date-key behavior. Those tests will catch the bugs most likely to hurt users.</p>
<hr />
<h2>Conclusion</h2>
<p>EaseOut is an ambitious app in a small package. It combines authentication, a dashboard, a password vault, job tracking, projects, profiles, a calendar, a study planner, custom spaces, local notifications, and two AI-assisted workflows. The code is readable, the Firebase model is easy to follow, and the UI is consistent across many screens.</p>
<p>It is also a clear example of the distance between a working prototype and a production system. The vault stores plaintext credentials. The AI key is embedded in the client and the model name is retired. CSV import uses a fragile parser and one unbounded batch. The release build uses a debug key. CI has no tests and currently fails on its own placeholder secret. None of that is hidden, which is exactly why the repository is useful to study.</p>
<p>The fixes are well understood: move secrets and privileged AI calls behind a backend, encrypt the vault, add rules and tests, make dates local, chunk large writes, and replace placeholders with configuration. Those changes would tighten the boundaries the app already has rather than require a rewrite.</p>
<p>That is the lesson: a serverless mobile app can move quickly, but it cannot skip server-side thinking. Firebase removes the infrastructure, not the trust boundary.</p>
<hr />
<h2>References</h2>
<ul>
<li><a href="https://github.com/vishalkumarojha/PassX-Easeout">EaseOut repository</a></li>
<li><a href="https://expo.dev/changelog/sdk-54">Expo SDK 54 changelog</a></li>
<li><a href="https://docs.expo.dev/versions/v54.0.0/sdk/filesystem-legacy/">Expo FileSystem legacy API</a></li>
<li><a href="https://reactnative.dev/blog/2025/08/12/react-native-0.81">React Native 0.81 release</a></li>
<li><a href="https://github.com/invertase/react-native-firebase">React Native Firebase</a></li>
<li><a href="https://firebase.google.com/docs/auth/web/start">Firebase Auth documentation</a></li>
<li><a href="https://firebase.google.com/docs/firestore/security/get-started">Firestore security rules</a></li>
<li><a href="https://firebase.google.com/docs/firestore/manage-data/transactions">Firestore batched writes and transactions</a></li>
<li><a href="https://firebase.google.com/docs/firestore/quotas">Firestore quotas and limits</a></li>
<li><a href="https://firebase.google.com/docs/storage/web/start">Firebase Storage documentation</a></li>
<li><a href="https://firebase.google.com/docs/crashlytics/get-started">Firebase Crashlytics documentation</a></li>
<li><a href="https://reactnavigation.org/docs/auth-flow/">React Navigation authentication flow</a></li>
<li><a href="https://reactnavigation.org/docs/native-stack-navigator/">React Navigation native stack</a></li>
<li><a href="https://notifee.app/react-native/docs/triggers/">Notifee triggers</a></li>
<li><a href="https://notifee.app/react-native/docs/android/channels/">Notifee Android channels</a></li>
<li><a href="https://github.com/invertase/notifee">Notifee repository</a></li>
<li><a href="https://ai.google.dev/gemini-api/docs/generate-content/get-started">Gemini generateContent</a></li>
<li><a href="https://ai.google.dev/gemini-api/docs/deprecations">Gemini model deprecations</a></li>
<li><a href="https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs">GitHub Actions for Node.js</a></li>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/basic-types.html">TypeScript handbook</a></li>
<li><a href="https://github.com/wix/react-native-calendars">react-native-calendars</a></li>
<li><a href="https://github.com/indiespirit/react-native-chart-kit">react-native-chart-kit</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How Notion Scales a Simple User Interface: Blocks, Databases, and On-Demand Loading]]></title><description><![CDATA[A technical exploration of Notion content model database views navigation patterns and the engineering tradeoff behind a flexible workspace]]></description><link>https://vishalkumarojha.hashnode.dev/how-notion-scales-a-simple-user-interface-blocks-databases-and-on-demand-loading</link><guid isPermaLink="true">https://vishalkumarojha.hashnode.dev/how-notion-scales-a-simple-user-interface-blocks-databases-and-on-demand-loading</guid><category><![CDATA[notion]]></category><dc:creator><![CDATA[Vishal Ojha]]></dc:creator><pubDate>Mon, 02 Nov 2026 00:11:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66fc3f0d2d6a5ab57529b5fe/bf066329-ba71-4d02-a3ad-cb4e445f3cc3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Picture a six-person product team at a fictional company, Northwind, getting ready to ship a redesigned onboarding flow. Two weeks in, their workspace holds a project page with goals and decisions, a task tracker with a few dozen items, notes from three kickoff meetings, and a personal dashboard where each teammate sees only their own work. Nobody on the team designed a schema, wrote a query, or thought about storage. They typed, dragged, filtered, and clicked.</p>
<blockquote>
<p><strong>A note on the example.</strong> Northwind and its people (Asha the PM, Ben the engineer, Carla the designer) are invented for illustration. They are not a Notion customer story, and nothing about them is evidence of how Notion works internally.</p>
</blockquote>
<p>That ease is the tension this article is about. The more kinds of content a workspace can hold, the harder it is to keep understandable and responsive. Prose, checklists, task boards, and calendars are very different shapes of information, yet one product holds all of them without asking people to manage the machinery underneath. How does it support documents, structured records, nested pages, and several views of the same data at once?</p>
<p>This article works from the outside in: the public content model first, then databases, views, linked views, and templates, and finally navigation, search, and performance. The sources are Notion's help center, its developer documentation, and a handful of posts from Notion's engineers.</p>
<h3>What this article will and won't claim</h3>
<p>Public documentation explains the object model and the behavior you can see. Notion's engineers have also written about the block model, sharding, client-side caching, and their data lake, and those posts are cited where they apply. They do not describe every production detail, and this article does not fill the gaps with guesses. Three labels keep the line visible:</p>
<ul>
<li><strong>Documented:</strong> stated in Notion's documentation or engineering posts, with a link and, for engineering posts, a date.</li>
<li><strong>Analysis:</strong> general software-engineering reasoning about why a design might work. It is not a claim about Notion.</li>
<li><strong>Conceptual:</strong> an example or diagram invented for this article to teach an idea.</li>
</ul>
<p>One more limit: this is a research article, not a benchmark. No timing here comes from our own testing. A section near the end describes a small experiment you can run yourself.</p>
<h2>One workspace, many kinds of content</h2>
<p>Think about what Northwind puts into its workspace: paragraphs and headings, checklists, screenshots, tables, individual tasks, whole projects, and records such as bug reports. A model that treats a page as one long document handles prose well. It struggles the moment someone asks, "Show me everything due this week that's assigned to Ben." A spreadsheet answers that easily, but it squeezes a design rationale or a meeting discussion into a cell.</p>
<p>A workspace tool has to satisfy several requirements that pull against each other:</p>
<ul>
<li><strong>Flexibility:</strong> people create different kinds of content without asking permission.</li>
<li><strong>Structure:</strong> records have properties, such as status or owner, that can be filtered and sorted.</li>
<li><strong>Composability:</strong> the same information can appear in more than one place without being copied.</li>
<li><strong>Discoverability:</strong> people can find things as the workspace grows.</li>
<li><strong>Performance:</strong> the app does not have to present everything at once.</li>
</ul>
<p>Pushing hard on one tends to cost another. The rest of this article follows a handful of concepts that balance them: blocks, pages, databases, views, templates, and loading only what the current interaction needs.</p>
<p>The table below fixes the vocabulary. Its definitions follow Notion's help documentation on <a href="https://notion.com/help/data-sources-and-linked-databases">databases and data sources</a> and on <a href="https://www.notion.com/en-gb/help/views-filters-and-sorts">views</a>.</p>
<table>
<thead>
<tr>
<th>Concept</th>
<th>What it is</th>
<th>Northwind example</th>
</tr>
</thead>
<tbody><tr>
<td>Document (page)</td>
<td>A page holding free-form content: text, headings, lists, media, even nested pages</td>
<td>The "Onboarding Revamp" project page</td>
</tr>
<tr>
<td>Database record (entry)</td>
<td>One item in a database. Each row opens as its own page and carries properties</td>
<td>The task "Ship onboarding flow", with a status, owner, and due date</td>
</tr>
<tr>
<td>Database view</td>
<td>A saved way of displaying a collection: layout, filter, sort, grouping, visible properties</td>
<td>A board of tasks grouped by status</td>
</tr>
</tbody></table>
<p>The diagram below is <strong>conceptual</strong>. It sketches how different kinds of content land in a workspace. It is not a picture of Notion's internal architecture.</p>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBBWyJQcm9zZSBhbmQgaGVhZGluZ3MiXSAtLT4gUFsiUGFnZSBjb250ZW50Il0KICAgIEJbIkNoZWNrbGlzdHMgYW5kIG1lZGlhIl0gLS0-IFAKICAgIENbIlRhc2tzLCBidWdzLCBwcm9qZWN0cyJdIC0tPiBFWyJEYXRhYmFzZSBlbnRyaWVzIl0KICAgIEUgLS0-fCJlYWNoIG9wZW5zIGFzInwgUAogICAgRSAtLT58ImFsc28gY2FycmllcyJ8IEZbIlByb3BlcnRpZXM6IHN0YXR1cywgb3duZXIsIGRhdGUiXQogICAgRFsiRGF0YWJhc2UiXSAtLT58ImNvbnRhaW5zIG1hbnkifCBF?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual flowchart showing prose, headings, checklists, media, and database entries in a workspace." style="display:block;margin:0 auto" />

<p><em>Figure 1 (conceptual). Prose and media become page content. Records become database entries, which open as pages and carry properties.</em></p>
<h2>Understanding the block model</h2>
<h3>What a block is</h3>
<p>A block is a single unit of content: a paragraph, a heading, a to-do item, an image, a toggle. In a <a href="https://www.notion.com/blog/data-model-behind-notion">2021 engineering post on Notion's data model</a>, Notion describes blocks as the foundation of the product. Text, images, lists, database rows, and even pages are all blocks in that model.</p>
<p>According to that post, every block has a few core attributes:</p>
<ul>
<li>an <strong>ID</strong>, a randomly generated UUID (version 4);</li>
<li><strong>properties</strong>, a bundle of attributes, of which <code>title</code> is the most common (the text of a paragraph, or the name of a page);</li>
<li>a <strong>type</strong>, which decides how the block is displayed and how its properties are interpreted;</li>
<li><strong>content</strong>, an ordered list of IDs for the blocks nested inside it;</li>
<li>a <strong>parent</strong>, the ID of the block above it, which the post says is used only for permissions.</li>
</ul>
<p><em>Documented: Notion Engineering, May 18, 2021.</em></p>
<h3>Why smaller units help</h3>
<p>Treating a page as one blob of text makes every operation coarse: reordering a checklist item or turning a paragraph into a heading becomes an edit to one large document. With blocks, each is an edit to a small record.</p>
<p>The same post gives a neat example. The "Turn into" command changes only a block's type, and the stored properties stay put. A checked to-do that becomes a heading and back again is still checked, because the <code>checked</code> property was merely ignored while the block displayed as a heading. Keeping properties independent of display type makes transformations cheap and preserves what the user meant.</p>
<p>Nesting follows the same logic. A block's content list defines what the post calls the render tree, and block types render their children differently: list-style blocks indent them, a toggle shows them only when expanded, and a page block shows them on its own page. Even indentation is structural. Pressing indent moves a block into the content of the block above it.</p>
<h3>A simplified picture</h3>
<p>Northwind's project page might look like the snippet below. It is <strong>conceptual and simplified</strong>. It borrows vocabulary from Notion's <a href="https://developers.notion.com/guides/data-apis/working-with-page-content">API documentation</a> but is not an exact production payload.</p>
<pre><code class="language-markdown">{
  "page": "Onboarding Revamp",
  "children": [
    { "type": "heading_2", "text": "Goals" },
    { "type": "paragraph", "text": "Shorten the time to a new user's first success." },
    {
      "type": "to_do",
      "text": "Draft the welcome screen",
      "checked": false,
      "children": [
        { "type": "to_do", "text": "Review the copy", "checked": false }
      ]
    },
    { "type": "child_page", "title": "Kickoff meeting notes" }
  ]
}
</code></pre>
<p>In the public API, a page's content is a list of typed block objects, and blocks that can nest report whether they have children. A page is itself a special kind of block, and a sub-page appears in its parent as a <code>child_page</code> reference rather than an inline copy. <em>Documented: Notion developer documentation.</em></p>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBXWyJXb3Jrc3BhY2UiXSAtLT4gUEdbIlBhZ2U6IE9uYm9hcmRpbmcgUmV2YW1wIl0KICAgIFBHIC0tPiBIWyJIZWFkaW5nIGJsb2NrOiBHb2FscyJdCiAgICBQRyAtLT4gUEFbIlBhcmFncmFwaCBibG9jazogV2h5IHRoaXMgbWF0dGVycyJdCiAgICBQRyAtLT4gVERPWyJUby1kbyBibG9jazogRHJhZnQgdGhlIHdlbGNvbWUgc2NyZWVuIl0KICAgIFBHIC0tPiBTUFsiU3ViLXBhZ2UgYmxvY2s6IEtpY2tvZmYgbWVldGluZyBub3RlcyJdCiAgICBURE8gLS0-IE5bIk5lc3RlZCB0by1kbzogUmV2aWV3IHRoZSBjb3B5Il0KICAgIFNQIC0tPiBNWyJCbG9ja3MgaW5zaWRlIHRoZSBzdWItcGFnZSJd?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual flowchart of the Notion block hierarchy: workspace, page, heading, paragraph, nested to-do, and sub-page." style="display:block;margin:0 auto" />

<p><em>Figure 2 (conceptual). The public content model: a workspace holds pages, pages hold blocks, and blocks can nest.</em></p>
<h3>What the model costs</h3>
<p>The same post shows the price of this flexibility. Because blocks nest and permissions flow downward, Notion keeps a downward content pointer and an upward parent pointer, and the two need to mirror each other. Blocks were once allowed to appear in more than one content list, which left it unclear which parent they should inherit permissions from, and an unclear parent is unacceptable in a permission system. Finding ancestors by scanning content lists is also slow, especially on the client.</p>
<p>The cost reappears at scale. In a <a href="https://www.notion.com/blog/building-and-scaling-notions-data-lake">2024 post on its data lake</a>, Notion explains that a block's permissions are not stored statically. They are built by walking the ancestor tree up to the workspace, and with hundreds of billions of blocks that computation timed out in their data warehouse at the time.</p>
<p>A caution: an API representation and a few engineering posts describe a <em>conceptual</em> data model, not the production schema. The data-lake post does say every item is a block entity in the backend, stored in Postgres with a consistent structure. We treat that as confirmed and leave column-level details alone.</p>
<p>In short: blocks represent many content types uniformly and make small edits cheap, but ordering, nesting, permissions, and synchronization now live in the relationships between blocks.</p>
<h2>Pages, blocks, and databases: related but not identical</h2>
<p>A <strong>page</strong> is both a container for content and a destination you navigate to. In Notion's model it is also a block. The 2021 post lists pages among the things that are blocks, and the API guide calls a page a special kind of block with children of its own.</p>
<p>A <strong>database</strong> is a structured collection of pages. Notion's help center describes a data source as a set of pages, and each row in a table view opens as its own page. A record can therefore carry two kinds of information at once: <strong>properties</strong> (typed fields) and a <strong>body</strong> (free-form content). The API guide offers a useful rule of thumb: properties suit structured information such as a due date, a category, or a relationship, while body content suits looser, free-form writing.</p>
<p>Northwind's task "Ship onboarding flow" shows both halves. Its properties are Status, Owner, and Due date. Its body holds a short design rationale, a link to the mockups, and a sub-page of review notes. Filters and boards work on the properties; people reading the task work in the body. Because the record is a page, it can grow from a one-line task into a small document.</p>
<blockquote>
<p><strong>A terminology note.</strong> Notion's current help documentation says a database is made up of one or more <em>data sources</em>, and its API now treats databases and data sources as separate objects (<a href="https://developers.notion.com/reference/query-a-data-source">query a data source</a>). This article says "database" when the distinction doesn't matter and "data source" when it does.</p>
</blockquote>
<table>
<thead>
<tr>
<th>Concept</th>
<th>What it represents</th>
<th>Northwind example</th>
<th>Where it is documented</th>
</tr>
</thead>
<tbody><tr>
<td>Page</td>
<td>A destination and container for content</td>
<td>The project overview</td>
<td><a href="https://www.notion.com/blog/data-model-behind-notion">Engineering post</a>, <a href="https://developers.notion.com/guides/data-apis/working-with-page-content">API guide</a></td>
</tr>
<tr>
<td>Block</td>
<td>A unit of content</td>
<td>A heading or checklist item</td>
<td><a href="https://www.notion.com/blog/data-model-behind-notion">Engineering post</a></td>
</tr>
<tr>
<td>Database</td>
<td>A structured collection of entries</td>
<td>The team task tracker</td>
<td><a href="https://notion.com/help/data-sources-and-linked-databases">Help center</a></td>
</tr>
<tr>
<td>Database entry</td>
<td>One record, which is also a page</td>
<td>"Ship onboarding flow"</td>
<td><a href="https://www.notion.com/en-gb/help/views-filters-and-sorts">Help center</a></td>
</tr>
<tr>
<td>View</td>
<td>A way of displaying a collection</td>
<td>A board grouped by status</td>
<td><a href="https://www.notion.com/en-gb/help/views-filters-and-sorts">Help center</a></td>
</tr>
</tbody></table>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBEQlsiRGF0YWJhc2U6IFRlYW0gVGFza3MiXSAtLT4gVDFbIkVudHJ5OiBTaGlwIG9uYm9hcmRpbmcgZmxvdyJdCiAgICBEQiAtLT4gVDJbIkVudHJ5OiBXcml0ZSB3ZWxjb21lIGNvcHkiXQogICAgVDEgLS0-IFBSMVsiUHJvcGVydGllczogU3RhdHVzLCBPd25lciwgRHVlIl0KICAgIFQxIC0tPiBCRDFbIlBhZ2UgYm9keTogbm90ZXMsIGxpbmtzLCBzdWItcGFnZXMiXQogICAgVDIgLS0-IFBSMlsiUHJvcGVydGllcyJdCiAgICBUMiAtLT4gQkQyWyJQYWdlIGJvZHkiXQ?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual flowchart showing a task database, database entries, properties, and page-body content." style="display:block;margin:0 auto" />

<p><em>Figure 3 (conceptual). Each database entry combines structured properties with a page body. The diagram shows what the product exposes, not an internal schema.</em></p>
<h2>Database views: different windows onto the same information</h2>
<p>Notion's help center lists seven view layouts: table, board, timeline, calendar, list, gallery, and chart. Forms can also be connected to a database. A new database starts as a table, and you add other views alongside it.</p>
<p>Why would one collection need so many presentations? Because Northwind's people ask different questions of the same tasks. Asha, the PM, wants a board grouped by status to see what is stuck. Ben wants a plain list of what is assigned to him. The team wants a calendar showing what lands before launch. The data is the same; the questions are not.</p>
<p>A view is shaped by a handful of controls: layout, visible properties, filters, sorts, and grouping with optional sub-groups. Filters can combine AND and OR logic in groups nested up to three layers deep. Each view keeps its own settings, and filters and sorts can be saved for everyone or kept private to you. <em>Documented:</em> <a href="https://www.notion.com/en-gb/help/views-filters-and-sorts"><em>Notion Help, "Views, filters, sorts &amp; groups"</em></a><em>.</em></p>
<p>The most important distinction in this section is that <strong>changing a view is not changing data</strong>. Filtering to Ben's tasks hides everyone else's tasks from that view and alters nothing. Editing a task's status, on the other hand, edits that task's page. <em>Analysis:</em> since each row is a page, it is natural that every view reads and writes the same underlying pages, which is why a status change on the board also shows up in the list. The documentation describes the behavior of views and pages but does not spell out the mechanism.</p>
<p>Here is the before-and-after for Northwind's tasks:</p>
<ul>
<li><strong>Unfiltered view:</strong> all of the team's tasks, every property visible.</li>
<li><strong>Personal view:</strong> Owner is Ben, sorted by due date. A short list of what he must do next.</li>
<li><strong>Planning view:</strong> a board grouped by Status and sub-grouped by project. A picture of flow rather than a list of items.</li>
</ul>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBEU1siT25lIHRhc2sgY29sbGVjdGlvbiAoZGF0YSBzb3VyY2UpIl0gLS0-IFYxWyJUYWJsZSB2aWV3OiBhbGwgdGFza3MsIGV2ZXJ5IHByb3BlcnR5Il0KICAgIERTIC0tPiBWMlsiQm9hcmQgdmlldzogZ3JvdXBlZCBieSBTdGF0dXMiXQogICAgRFMgLS0-IFYzWyJMaXN0IHZpZXc6IGZpbHRlcmVkIHRvIE93bmVyID0gQmVuIl0KICAgIERTIC0tPiBWNFsiQ2FsZW5kYXIgdmlldzogcGxhY2VkIGJ5IER1ZSBkYXRlIl0?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual flowchart showing one task collection displayed as table, board, filtered list, and calendar views." style="display:block;margin:0 auto" />

<p><em>Figure 4 (conceptual view model). One logical collection, several configured views. The diagram does not claim that each view runs its own query or that any particular caching strategy is involved.</em></p>
<h2>Linked databases and contextual dashboards</h2>
<p>Imagine Northwind copying its task list into the project page, Ben's dashboard, and the weekly review page. It works for a day. Then a status changes in one place and not in the others, and the copies drift apart. Duplication is a consistency problem.</p>
<p>Notion's answer is to keep one source and show it elsewhere. The help center explains that you can create a database that links to an existing data source, so the same pages appear in a new location, and each location gets its own views, filters, sorts, and groups. Three documented behaviors matter. Views you create or delete in the linked location do not change the views on the original. Edits to the data source's title, properties, or pages are reflected in the original. And a linked data source respects the access level of the original database. <em>Documented:</em> <a href="https://notion.com/help/data-sources-and-linked-databases"><em>Notion Help, "Data sources &amp; linked databases"</em></a><em>.</em></p>
<p>Terminology has shifted: Notion's <a href="https://www.notion.com/en-gb/help/optimize-database-load-times-and-performance">performance guide</a> still mentions a "linked view of a database" in the slash menu, so older and newer wording coexist.</p>
<p>Here is the Northwind walkthrough:</p>
<ol>
<li>Create a Team Tasks database with Title, Owner, Status, and Due date.</li>
<li>On Ben's personal dashboard, add a linked view of Team Tasks.</li>
<li>Filter that view to Owner is Ben and sort by Due date.</li>
<li>On the project page, add a second linked view of the same tasks, grouped by Status.</li>
<li>Edit a task from either place and confirm that the source and the other view agree.</li>
</ol>
<p>The principle is simple: centralize the information, then tailor how it is presented.</p>
<blockquote>
<p><strong>Try it yourself.</strong> These steps follow the documentation, not a recorded experiment. Menu names change between releases, and some features depend on your plan, so check the current help pages. One practical catch from the FAQ: if you share a page that contains a linked data source, the recipient also needs access to the original database to see it.</p>
</blockquote>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBTUkNbIlNvdXJjZTogVGVhbSBUYXNrcyBkYXRhYmFzZSJdCiAgICBTUkMgLS0-IEwxWyJQZXJzb25hbCBkYXNoYm9hcmQ6IGxpbmtlZCB2aWV3LCBPd25lciA9IEJlbiwgc29ydGVkIGJ5IER1ZSJdCiAgICBTUkMgLS0-IEwyWyJQcm9qZWN0IHBhZ2U6IGxpbmtlZCB2aWV3LCBncm91cGVkIGJ5IFN0YXR1cyJdCiAgICBMMSAtLi0-fCJlZGl0aW5nIGEgdGFzayBoZXJlInwgU1JDCiAgICBMMiAtLi0-fCJlZGl0aW5nIGEgdGFzayBoZXJlInwgU1JD?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual flowchart showing a source task database connected to personal and project linked views." style="display:block;margin:0 auto" />

<p><em>Figure 5 (conceptual). One source database surfaced in two contexts, each with its own view configuration. Edits made through either linked view change the source pages.</em></p>
<h2>Templates and modular workflows</h2>
<p>Most databases hold the same kinds of pages over and over: weekly meeting notes, bug reports, design specs. Notion's help center uses exactly those examples to motivate <strong>database templates</strong>, which define a page structure once and replicate it with one click. A template can pre-fill properties (a bug template might set a priority and a product manager), and it can carry any content, including images, embeds, and sub-pages. Once a page has been created from a template, you can edit it freely. <em>Documented:</em> <a href="https://www.notion.com/en-gb/help/database-templates"><em>Notion Help, "Database templates"</em></a><em>.</em></p>
<p>A few documented details show how templates are scoped. Templates belong to the database where you created them and are not shared across your workspace. A template can be set to repeat daily, weekly, monthly, or yearly. Nesting a database with its own templates inside a recurring template is possible, with limits: three levels, and not inside a daily-recurring template. The docs also warn against filling in a relation property on a template unless you want every page created from it to point at the same related pages.</p>
<p>Northwind turns its project brief into a template with six sections:</p>
<ul>
<li><strong>Goals:</strong> what success looks like.</li>
<li><strong>Owner:</strong> who is accountable.</li>
<li><strong>Milestones:</strong> dated checkpoints.</li>
<li><strong>Risks:</strong> what could go wrong.</li>
<li><strong>Decision log:</strong> what was decided, and why.</li>
<li><strong>Follow-up tasks:</strong> a place where next steps land.</li>
</ul>
<p>Every new project now starts with the same skeleton, and teams tweak what they need. Because content is made of independent blocks, a template is a pre-arranged set of them, and a page created from it is free to diverge.</p>
<p>Templates are about <strong>workflow consistency and ease of starting</strong>, not scale or performance, and Notion's documentation does not present them as a performance mechanism. They matter here because they show how a flexible system stays usable: by making the common path easy.</p>
<h2>Loading and performance: what users experience versus what we can prove</h2>
<h3>Six techniques that often get mixed up</h3>
<p>Large interfaces usually avoid presenting everything at once, and a familiar set of general techniques supports that. They are not synonyms:</p>
<ul>
<li><strong>Lazy loading</strong> defers fetching or building something until it is needed.</li>
<li><strong>Pagination</strong> (chunked fetching) requests data in manageable pieces.</li>
<li><strong>Virtualization</strong> renders only the list items currently visible.</li>
<li><strong>Caching</strong> keeps results nearby so repeated requests do less work.</li>
<li><strong>Indexing</strong> organizes data so matching records can be found quickly.</li>
<li><strong>Sharding</strong> partitions data or load across separate databases.</li>
</ul>
<p><em>Analysis:</em> a product with Notion's requirements might plausibly use several of these. What follows separates what Notion has said from what it hasn't.</p>
<h3>What Notion has said</h3>
<p><strong>Documented: client caching.</strong> The 2021 post describes opening a page by trying local data first: in-memory blocks on the web, and a local store called RecordCache (a least-recently-used cache over SQLite or IndexedDB) on native apps. Only when data is missing does the client call the API's <code>loadPageChunk</code> method, which descends the content tree from a starting block and returns the blocks plus the records needed to render them. The post mentions several caching layers around that call but gives no chunk sizes or rules for requesting more.</p>
<p>A <a href="https://www.notion.com/blog/how-we-sped-up-notion-in-the-browser-with-wasm-sqlite">July 2024 post</a> brings SQLite caching to the browser through WebAssembly. Notion reports roughly a 20 percent improvement in page-navigation time, but not in first load: loading the new library blocked first load, so Notion made it asynchronous. Notion also found that some slow devices read from disk slowly, so the app races the local cache against the API. These are Notion's own measurements, and we cannot verify them independently.</p>
<p><strong>Documented: sharding.</strong> Notion sharded Postgres at the application level, partitioning by workspace ID, and notes that people usually query within one workspace, so most cross-shard joins are avoided (<a href="https://notion.com/blog/sharding-postgres-at-notion">2021 post</a>). The data-lake post reports 480 logical shards: 32 physical instances with 15 each in 2021, then 96 with 5 each in 2023.</p>
<p><strong>Documented: scale.</strong> That post also says block data has doubled every 6 to 12 months, from over 20 billion block rows in early 2021 to over 200 billion by mid-2024. It is Notion's own figure at a specific date, with no independent measurement found. It is also easy to misread: the data lake serves offline workloads that tolerate minutes to hours of latency, not page loads.</p>
<p><strong>Indexing.</strong> The 2021 post says block text is indexed in the background for Quick Find after a save, and says no more. The data-lake post lists Elasticsearch among downstream stores and names search as a use case, but does not say it serves Quick Find, so we do not claim that.</p>
<table>
<thead>
<tr>
<th>General technique</th>
<th>What it can help with</th>
<th>Evidence about Notion</th>
</tr>
</thead>
<tbody><tr>
<td>Lazy loading</td>
<td>Defers work until needed</td>
<td>The 2021 post says toggles render children only when expanded (display behavior). Whether children are fetched lazily is not stated.</td>
</tr>
<tr>
<td>Pagination or chunked fetching</td>
<td>Limits data requested at once</td>
<td>Public API endpoints paginate (100 per page; data-source queries cap at 10,000 results). The app's <code>loadPageChunk</code> is described, but not its chunking rules.</td>
</tr>
<tr>
<td>Virtualized lists</td>
<td>Fewer rendered elements</td>
<td>Not publicly verified in sources reviewed.</td>
</tr>
<tr>
<td>Caching</td>
<td>Less repeated work</td>
<td>Confirmed: client-side RecordCache, SQLite in native apps and, since 2024, browsers; several unspecified server-side layers.</td>
</tr>
<tr>
<td>Indexing</td>
<td>Finds matching records</td>
<td>Block text indexed in the background for Quick Find; backend details not described.</td>
</tr>
<tr>
<td>Sharding</td>
<td>Spreads data and load</td>
<td>Confirmed: application-level Postgres sharding keyed by workspace ID.</td>
</tr>
</tbody></table>
<h3>What the help center says about database speed</h3>
<p>Notion's <a href="https://www.notion.com/en-gb/help/optimize-database-load-times-and-performance">performance guide</a> is the most direct user-facing evidence. It names three culprits: many pages, many visible properties, and complex sorts or filters on formulas and rollups. Its advice: hide unneeded properties, filter on simple properties, avoid long formula and rollup chains, and avoid stacking many inline databases on busy pages. A single linked database with several views keeps only one view open, so only one database listens for updates. Limits include 250,000 rows and 500 properties per database.</p>
<p><em>Analysis:</em> this suggests cost grows with rows, visible properties, and live views. The "listening for updates" wording fits the subscription model in the 2021 post, where clients subscribe to changes in records they render. Neither source links the two, so treat that as an informed guess.</p>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBWyJVc2VyIG9wZW5zIGEgcGFnZSJdIC0tPiBCeyJOZWVkZWQgZGF0YSBhbHJlYWR5IG9uIHRoZSBkZXZpY2U_In0KICAgIEIgLS0-fCJ5ZXMifCBDWyJSZW5kZXIgZnJvbSBsb2NhbCBkYXRhIl0KICAgIEIgLS0-fCJubyJ8IERbIlJlcXVlc3QgdGhlIGNvbnRlbnQgbmVlZGVkIGZvciB0aGlzIHZpZXciXQogICAgRCAtLT4gRVsiUmVuZGVyIHdoYXQgYXJyaXZlZCJdCiAgICBDIC0tPiBGWyJVc2VyIHNjcm9sbHMgb3IgZXhwYW5kcyJdCiAgICBFIC0tPiBGCiAgICBGIC0tPiBHeyJNb3JlIGNvbnRlbnQgbmVlZGVkPyJ9CiAgICBHIC0tPnwieWVzInwgRAogICAgRyAtLT58Im5vInwgSFsiU3RheSBvbiB0aGlzIHZpZXciXQ?type=png&amp;theme=dark&amp;bgColor=111827" alt="Generic on-demand loading flowchart showing local data checks, remote requests, rendering, and requests for additional content." style="display:block;margin:0 auto" />

<p><em>Figure 6 (generic interaction model). A common pattern for on-demand loading. It is not a reverse-engineered Notion implementation.</em></p>
<p>The honest summary: Notion has confirmed local caching, sharding by workspace, and background indexing for search. It has described a chunked page-loading call without giving its rules. For virtualization and for exactly which blocks are fetched while you scroll, the public sources reviewed say nothing, so this article says nothing either.</p>
<h2>Search, navigation, and information architecture</h2>
<p><strong>Hierarchy.</strong> Notion's <a href="https://www.notion.com/help/navigate-with-the-sidebar">sidebar guide</a> says pages can nest with no limit, and the sidebar splits into Teamspaces (on paid plans), Shared, and Private sections. A full-page database's views appear as nested items there. An older onboarding guide, <a href="https://classroom-physicists.physics.mcgill.ca/documentation/notes/quick-guide-to-using-notion/navigate-your-workspace">mirrored here</a>, also describes a breadcrumb trail showing a page's place in the hierarchy. That is a secondary copy, so treat the wording loosely.</p>
<p><strong>Search.</strong> The <a href="https://www.notion.com/help/search">search help page</a> describes a window opened from the sidebar or a shortcut, with recent pages, exact-phrase matching in quotes, sorting (best matches by default, which favors recent edits and titles over page contents), and filters such as title only, creator, teamspace, parent page, and date. It also lists gaps: comments and discussions are not searched, nor are select-style tag values, and most page and person mentions are skipped.</p>
<p><strong>Filters and database search.</strong> A filtered view returns a predictable subset of records. Database search is separate: it matches page titles and property values but not page contents, while workspace search covers content inside database pages.</p>
<p>The trade-off is real. Deep nesting keeps a workspace tidy, but it can also hide information from anyone who doesn't know where to look. Search and filters answer different questions. Search helps when you remember a clue: "the kickoff decision about the welcome screen." A filtered view helps when you want the same predictable answer every day: "all overdue tasks assigned to Ben."</p>
<table>
<thead>
<tr>
<th>Mechanism</th>
<th>Best when</th>
<th>Northwind example</th>
<th>Limits</th>
</tr>
</thead>
<tbody><tr>
<td>Hierarchy and sidebar</td>
<td>You know where something lives</td>
<td>Project page under a "Launches" parent</td>
<td>Deep nesting can hide things</td>
</tr>
<tr>
<td>Breadcrumbs</td>
<td>You need orientation</td>
<td>Seeing which parent a meeting note sits in</td>
<td>Shows location, doesn't find content</td>
</tr>
<tr>
<td>Search</td>
<td>You remember a term or clue</td>
<td>Finding the kickoff meeting note</td>
<td>Some content types aren't searched</td>
</tr>
<tr>
<td>Filtered view</td>
<td>You want a predictable subset of records</td>
<td>Ben's overdue tasks</td>
<td>Covers only that database</td>
</tr>
<tr>
<td>Linked view</td>
<td>The same records matter in another context</td>
<td>Tasks on the project page</td>
<td>Each live view adds load</td>
</tr>
</tbody></table>
<p>We don't describe the sidebar's or search's internal index architecture, because no public source reviewed does.</p>
<h2>A conceptual end-to-end example</h2>
<p>Let's run Northwind through one launch, tying each step back to an earlier concept.</p>
<ol>
<li><strong>A project page is created from a template.</strong> Asha uses the project-brief template. The page starts with goals, owner, milestones, risks, a decision log, and follow-up tasks. <em>(Templates.)</em></li>
<li><strong>The page holds goals, decisions, and links.</strong> Each heading, paragraph, and checklist item is an independent block, and sub-pages nest beneath. <em>(Block model.)</em></li>
<li><strong>Work is tracked in a task database.</strong> Every task is an entry in Team Tasks, and every entry is also a page. <em>(Pages and databases.)</em></li>
<li><strong>Each task has properties.</strong> Owner, Status, and Due date make filtering, grouping, and calendars possible. <em>(Properties versus body.)</em></li>
<li><strong>A linked view surfaces tasks on the project page.</strong> It is grouped by Status so the team sees flow at a glance. <em>(Linked databases.)</em></li>
<li><strong>A personal dashboard shows a filtered set.</strong> Ben's linked view shows only his tasks, sorted by due date. <em>(Views and filters.)</em></li>
<li><strong>A meeting note captures decisions and follow-ups.</strong> The note lives under the project page, and follow-up actions become new tasks in the same database. <em>(Pages, databases.)</em></li>
<li><strong>People navigate to what they need.</strong> Asha browses the hierarchy, Carla searches for a clue, Ben opens his dashboard. Nobody treats the workspace as one giant document. <em>(Navigation and search.)</em></li>
</ol>
<img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBUUExbIlByb2plY3QgdGVtcGxhdGUiXSAtLT58ImNyZWF0ZXMifCBQUFsiUHJvamVjdCBwYWdlOiBPbmJvYXJkaW5nIFJldmFtcCJdCiAgICBQUCAtLT58ImxpbmtzIHRvInwgTU5bIk1lZXRpbmcgbm90ZXMiXQogICAgVERCWyJUZWFtIFRhc2tzIGRhdGFiYXNlIl0gLS0-fCJsaW5rZWQgdmlldyJ8IFBQCiAgICBUREIgLS0-fCJmaWx0ZXJlZCBsaW5rZWQgdmlldyJ8IERBU0hbIlBlcnNvbmFsIGRhc2hib2FyZDogQmVuIl0KICAgIE1OIC0tPnwiZm9sbG93LXVwIGFjdGlvbnMgYmVjb21lIHRhc2tzIGluInwgVERC?type=png&amp;theme=dark&amp;bgColor=111827" alt="Conceptual end-to-end workflow connecting a project template, project page, meeting notes, task database, and personal dashboard." style="display:block;margin:0 auto" />

<p><em>Figure 7 (conceptual). The Northwind workflow. Every arrow corresponds to a step above; there is no claim about infrastructure.</em></p>
<p>Notice what the story leaves out: where the data lives, how a board refreshes, what happens when two people edit at once. Notion's engineering posts answer parts of those questions, but the experience works because the <em>model</em> is coherent. Inventing infrastructure to make the example sound sophisticated would add nothing.</p>
<h2>Trade-offs and design lessons</h2>
<p>The model has costs, and they're worth stating plainly.</p>
<ul>
<li><strong>Flexibility versus consistency.</strong> People can shape a workspace to their needs, and teams can end up with inconsistent structures. Templates help, but they don't enforce anything.</li>
<li><strong>Hierarchy versus cross-linking.</strong> Nested pages are tidy; useful content can still get buried. Search and linked views compensate.</li>
<li><strong>Rich records versus simple tables.</strong> A database entry that is also a page holds more context, but newcomers have to learn that rows are pages, that properties differ from body content, and that a view is not the data.</li>
<li><strong>Many views versus cognitive load.</strong> Each view is relevant to someone, but a long row of tabs becomes something to maintain, and Notion's docs tie live views to load.</li>
<li><strong>Power versus performance.</strong> Rich, interactive interfaces need careful decisions about retrieval and rendering. Notion's explicit limits and tuning advice suggest the cost is real.</li>
</ul>
<p>Several lessons follow for engineers designing something similar. They are broader product-engineering principles that Notion's design illustrates, not discoveries unique to it.</p>
<ol>
<li><strong>Choose a coherent content model.</strong> One uniform unit, with a few well-defined relationships, scales better than many special cases.</li>
<li><strong>Separate data from presentation</strong> where users need multiple views of the same thing.</li>
<li><strong>Make common workflows easy to start and adapt.</strong> Templates do this without limiting what comes next.</li>
<li><strong>Design navigation for both hierarchy and search.</strong> Neither alone is enough.</li>
<li><strong>Measure performance instead of assuming that a clean interface is fast.</strong> Notion's own caching write-up shows an optimization that helped navigation and hurt first load until it was adjusted.</li>
<li><strong>Show complexity only when it's needed.</strong> Properties, filters, and view settings stay out of the way until someone asks for them.</li>
<li><strong>Record which conclusions are evidence and which are hypotheses.</strong> The hardest part of writing about any product's internals is declining to fill gaps with guesses.</li>
</ol>
<h2>How to explore these ideas yourself</h2>
<p>You can observe most of what this article describes in a free test workspace in under an hour. Remember that you will be observing product behavior, not private backend implementation. Plan-dependent features may differ.</p>
<ol>
<li>Create a project page.</li>
<li>Add headings, paragraphs, and a to-do list, then nest an item under another.</li>
<li>Create a task database with title, status, owner, and due-date properties.</li>
<li>Create at least two views with different filters or groupings.</li>
<li>Add a linked view to another page, if your plan supports it.</li>
<li>Create a project template with the six sections from earlier.</li>
<li>Edit an entry from different views and note what changes.</li>
<li>Record what is visible, what stays synchronized, and what the interface does while you open pages or scroll.</li>
</ol>
<p>Screenshots or timings are optional. If you capture timings, record the device, browser, workspace size, and steps, and treat the result as a single observation, not a benchmark.</p>
<p>Two things are worth watching: whether the list view agrees after you change a status on the board, and how a page with several inline databases feels next to one linked database with multiple views, as the performance guide suggests. Treat both as observations, not proof of mechanism.</p>
<p>A simple experiment log keeps the evidence honest:</p>
<ul>
<li><strong>Date and product version or context:</strong></li>
<li><strong>Workspace size:</strong></li>
<li><strong>Action tested:</strong></li>
<li><strong>Expected behavior:</strong></li>
<li><strong>Observed behavior:</strong></li>
<li><strong>Evidence captured:</strong></li>
<li><strong>Limitations:</strong></li>
</ul>
<h2>Scale is also an information-design problem</h2>
<p>Back at Northwind, nobody in the workspace thinks about blocks, shards, or caches. They see a page, a board, a calendar, and a search box. What makes that possible, according to Notion's public material, is a set of ideas that fit together: content in small, uniform units; pages that double as records; databases that separate data from the views people read it through; linked views that show one source in many contexts; templates that make the common path easy; and navigation that combines hierarchy with search.</p>
<p>Notion's engineers have also shown that the design has real costs, from the permission questions that come with nesting to documented limits on database size. Beyond the points confirmed above, we have said little about storage, fetch behavior, or scale, because public evidence does not support more.</p>
<p>For developers, the lesson is that a scalable workspace is not only a storage problem. It is also a problem of deciding what content means, how pieces of it relate, and how a person finds the right part of it. Which of those decisions would you prioritize first in a workspace application of your own: the content model, the views, or navigation? Share your answer in the comments.</p>
<h2>References and further reading</h2>
<p><em>Sources accessed on November 2, 2026. Help-center pages change often, so check the current versions. Locale-specific copies of some help pages (for example, en-gb) were the versions read.</em></p>
<p><strong>Notion engineering posts (primary)</strong></p>
<ol>
<li>Jake Teton-Landis, <a href="https://www.notion.com/blog/data-model-behind-notion">The data model behind Notion's flexibility</a>, Notion, May 18, 2021.</li>
<li>XZ Tie, Nathan Louie, Thomas Chow, Darin Im, Abhishek Modi, Wendy Jiao, <a href="https://www.notion.com/blog/building-and-scaling-notions-data-lake">Building and scaling Notion's data lake</a>, Notion, July 1, 2024.</li>
<li>Garrett Fidalgo, <a href="https://notion.com/blog/sharding-postgres-at-notion">Herding elephants: Lessons learned from sharding Postgres at Notion</a>, Notion, 2021.</li>
<li>Carlo Francisco, <a href="https://www.notion.com/blog/how-we-sped-up-notion-in-the-browser-with-wasm-sqlite">How we sped up Notion in the browser with WASM SQLite</a>, Notion, July 10, 2024.</li>
</ol>
<p><strong>Notion Help Center (primary)</strong></p>
<ol start="5">
<li><a href="https://www.notion.com/en-gb/help/views-filters-and-sorts">Views, filters, sorts &amp; groups</a>, Notion Help.</li>
<li><a href="https://notion.com/help/data-sources-and-linked-databases">Data sources &amp; linked databases</a>, Notion Help.</li>
<li><a href="https://www.notion.com/en-gb/help/database-templates">Database templates</a>, Notion Help.</li>
<li><a href="https://www.notion.com/en-gb/help/optimize-database-load-times-and-performance">Optimize database load times &amp; performance</a>, Notion Help.</li>
<li><a href="https://www.notion.com/help/search">Search in your workspace</a>, Notion Help.</li>
<li><a href="https://www.notion.com/help/navigate-with-the-sidebar">Navigate with the sidebar</a>, Notion Help.</li>
</ol>
<p><strong>Notion developer documentation (primary)</strong></p>
<ol start="11">
<li><a href="https://developers.notion.com/guides/data-apis/working-with-page-content">Working with page content</a>, Notion Developers.</li>
<li><a href="https://developers.notion.com/reference/query-a-data-source">Query a data source</a>, Notion Developers.</li>
</ol>
<p><strong>Secondary</strong></p>
<ol start="13">
<li><a href="https://classroom-physicists.physics.mcgill.ca/documentation/notes/quick-guide-to-using-notion/navigate-your-workspace">Navigate your workspace</a>, an educational mirror of an older Notion onboarding guide (used only for the breadcrumb description).</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[How I Built a Human-in-the-Loop AI Job Application Pipeline with Expo, Firestore, and Vercel]]></title><description><![CDATA[Build a human-in-the-loop AI job application pipeline with Expo, Firestore, Vercel, and Apps Script, including approval revocation, idempotency]]></description><link>https://vishalkumarojha.hashnode.dev/how-i-built-a-human-in-the-loop-ai-job-application-pipeline-with-expo-firestore-and-vercel</link><guid isPermaLink="true">https://vishalkumarojha.hashnode.dev/how-i-built-a-human-in-the-loop-ai-job-application-pipeline-with-expo-firestore-and-vercel</guid><category><![CDATA[AI]]></category><category><![CDATA[React Native]]></category><category><![CDATA[Firebase]]></category><category><![CDATA[serverless]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Vishal Ojha]]></dc:creator><pubDate>Sun, 01 Nov 2026 23:26:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66fc3f0d2d6a5ab57529b5fe/b7516e12-4eca-4b23-b98b-e88ce7732e4c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most "AI job application" projects optimize for volume. Paste a job description, generate a cover letter, fire it at a recruiter, repeat. That is easy to demo and risky to use. A wrong company name, an invented skill, or a resume sent before anyone read the draft is not a small bug. It is damage to a real person's reputation.</p>
<p>So I built the opposite: an AI-assisted pipeline that moves a job from raw text to a sent application in deliberate stages, with a human approval gate before anything leaves the system. The model can extract, compare, suggest, and draft. It cannot approve or send.</p>
<p>This article walks through the architecture, the data model, the approval and send safeguards, and the parts I would change next. The code lives in two projects:</p>
<ul>
<li><code>PassX-Easeout</code>: an Expo/React Native Android client.</li>
<li><code>jobemailer</code>: a Node.js/Vercel serverless backend.</li>
</ul>
<p>Together they form a working, offline-tested pipeline. Several external integrations still require real credentials and a live end-to-end run, and I call those out explicitly.</p>
<h2>What the system does</h2>
<p>The current pipeline has eight stages:</p>
<ol>
<li>Capture a job posting by pasting text or sharing it from another Android app.</li>
<li>Extract structured fields with an LLM and show the result with per-field confidence.</li>
<li>Let the user review and confirm the job before saving it.</li>
<li>Match the job against a resume library using five scored criteria.</li>
<li>Generate evidence-based resume suggestions without inventing experience.</li>
<li>Draft an outreach email grounded in the job and the selected resume.</li>
<li>Require a human to review and approve the exact draft, match, and resume.</li>
<li>Send through a server-side Apps Script bridge only after approval is revalidated.</li>
</ol>
<p>The important design decision is not "use an LLM." It is where the LLM stops.</p>
<blockquote>
<p>The model produces candidates. Deterministic code validates them. A human approves specific bytes. The server enforces that approval immediately before the external side effect.</p>
</blockquote>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgQVsiUmF3IGpvYiB0ZXh0PGJyLz5wYXN0ZSBvciBBbmRyb2lkIHNoYXJlIl0gLS0-IEJbIlBPU1QgL2FwaS9pbnRha2U8YnIvPkNsYXVkZSBleHRyYWN0aW9uIl0KICBCIC0tPiBDWyJIdW1hbiByZXZpZXdzIGZpZWxkczxici8-Y29uZmlkZW5jZSBhbmQgZWRpdHMiXQogIEMgLS0-fGNvbmZpcm1lZHwgRFsiQ2Fub25pY2FsIEpvYjxici8-aW4gRmlyZXN0b3JlIl0KICBDIC0tPnxuZWVkcyBjaGFuZ2VzfCBDCiAgRCAtLT4gRVsiUE9TVCAvYXBpL21hdGNoPGJyLz41IGNyaXRlcmlhLCBtYXggMTUgcmVzdW1lcyJdCiAgRSAtLT4gRlsiU3VnZ2VzdGlvbnM8YnIvPmV2aWRlbmNlIHJlcXVpcmVkIl0KICBGIC0tPiBHWyJEcmFmdDxici8-Z3JvdW5kZWQgaW4gam9iIGFuZCByZXN1bWUiXQogIEcgLS0-IEhbIkh1bWFuIHJldmlldzxici8-c3RhcnQgcmV2aWV3Il0KICBIIC0tPiBJeyJBcHByb3ZlPyJ9CiAgSSAtLT58bm98IEpbIlJlamVjdCBvciByZW9wZW4iXQogIEogLS0-IEgKICBJIC0tPnx5ZXN8IEtbIlBPU1QgL2FwaS9zZW5kPGJyLz5yZXZhbGlkYXRlIGFuZCBjbGFpbSJdCiAgSyAtLT4gTFsiQXBwcyBTY3JpcHQgdG8gR21haWwiXQogIEwgLS0-IE1bIkZpcmVzdG9yZSBsaWZlY3ljbGUgZmllbGRzIl0K?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 1: Human-in-the-loop AI job application pipeline from raw job text through extraction, user confirmation, resume matching, suggestions, email drafting, human approval, and sending." /></p>
<h2>Stack and repository shape</h2>
<p>The mobile app uses Expo SDK 54, React Native 0.81.5, React 19.1, and TypeScript 5.9. Firebase handles authentication, Firestore, storage, and Crashlytics. Notifee handles local notifications. The app is Android-only in this repository: <code>android/</code> is committed and <code>ios/</code> is not.</p>
<p>The backend is plain ESM JavaScript on Node.js 20 or newer, deployed as Vercel serverless functions. It uses <code>@anthropic-ai/sdk</code>, <code>firebase-admin</code>, <code>googleapis</code>, and <code>pdf-parse</code>. There is no build step.</p>
<p>The two projects meet at a small, typed HTTP contract:</p>
<table>
<thead>
<tr>
<th>Endpoint</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>POST /api/intake</code></td>
<td>Extract structured fields from raw job text</td>
</tr>
<tr>
<td><code>POST /api/match</code></td>
<td>Score resumes against a confirmed job</td>
</tr>
<tr>
<td><code>POST /api/suggestions</code></td>
<td>Propose evidence-based resume improvements</td>
</tr>
<tr>
<td><code>POST /api/draft</code></td>
<td>Draft an outreach email</td>
</tr>
<tr>
<td><code>POST /api/approval</code></td>
<td>Record a human review decision</td>
</tr>
<tr>
<td><code>POST /api/send</code></td>
<td>Send an approved application through Apps Script</td>
</tr>
<tr>
<td><code>GET /api/resumes</code></td>
<td>List public resume metadata</td>
</tr>
<tr>
<td><code>POST /api/reminders/run</code></td>
<td>Daily reminder sweep</td>
</tr>
<tr>
<td><code>POST /api/push/register</code></td>
<td>Register an Expo push token</td>
</tr>
<tr>
<td><code>POST /api/rpc</code></td>
<td>Legacy dashboard bridge</td>
</tr>
</tbody></table>
<p>The user-facing authenticated routes use a Firebase ID token. The reminder cron uses a separate <code>CRON_SECRET</code> bearer token. The server derives the user ID from the verified token and never accepts a <code>uid</code> from the request body.</p>
<h2>Architecture at a glance</h2>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgc3ViZ3JhcGggQW5kcm9pZFsiRXhwbyBBbmRyb2lkIGFwcCJdCiAgICBVSVsiSm9iIHBpcGVsaW5lIFVJIl0KICAgIEludGFrZVsiU21hcnQgSW50YWtlIl0KICAgIFJlcG9bIkZpcmVzdG9yZSByZXBvc2l0b3J5Il0KICAgIEFQSUNsaWVudFsiVHlwZWQgQVBJIGNsaWVudCJdCiAgICBMb2NhbE5vdGlmeVsiTG9jYWwgcmVtaW5kZXJzIl0KICBlbmQKCiAgc3ViZ3JhcGggVmVyY2VsWyJqb2JlbWFpbGVyIG9uIFZlcmNlbCJdCiAgICBBdXRoWyJGaXJlYmFzZSB0b2tlbiB2ZXJpZmljYXRpb24iXQogICAgQUlbIkludGFrZSAvIE1hdGNoIC8gU3VnZ2VzdGlvbnMgLyBEcmFmdCJdCiAgICBBcHByb3ZhbFsiQXBwcm92YWwgc3RhdGUgbWFjaGluZSJdCiAgICBTZW5kWyJTZW5kIG9yY2hlc3RyYXRvciJdCiAgICBDcm9uWyJSZW1pbmRlciBjcm9uIl0KICBlbmQKCiAgQW50aHJvcGljWyJBbnRocm9waWMgQVBJIl0KICBGaXJlc3RvcmVbKCJGaXJlc3RvcmUgdXNlcnMve3VpZH0vam9icyIpXQogIFNoZWV0WygiR29vZ2xlIFNoZWV0IHJlc3VtZSBpbmRleCIpXQogIERyaXZlWygiR29vZ2xlIERyaXZlIFBERnMiKV0KICBBcHBzU2NyaXB0WyJBcHBzIFNjcmlwdCBicmlkZ2UiXQogIEdtYWlsWyJHbWFpbCJdCiAgRXhwb1B1c2hbIkV4cG8gcHVzaCBzZXJ2aWNlIl0KCiAgVUkgLS0-IEFQSUNsaWVudAogIEludGFrZSAtLT4gQVBJQ2xpZW50CiAgUmVwbyA8LS0-IEZpcmVzdG9yZQogIEFQSUNsaWVudCAtLT4gQXV0aAogIEF1dGggLS0-IEFJCiAgQXV0aCAtLT4gQXBwcm92YWwKICBBdXRoIC0tPiBTZW5kCiAgQUkgLS0-IEFudGhyb3BpYwogIEFJIC0tPiBGaXJlc3RvcmUKICBBSSAtLT4gU2hlZXQKICBBcHByb3ZhbCAtLT4gRmlyZXN0b3JlCiAgU2VuZCAtLT4gRmlyZXN0b3JlCiAgU2VuZCAtLT4gU2hlZXQKICBTZW5kIC0tPiBBcHBzU2NyaXB0CiAgQXBwc1NjcmlwdCAtLT4gRHJpdmUKICBBcHBzU2NyaXB0IC0tPiBHbWFpbAogIENyb24gLS0-IEZpcmVzdG9yZQogIENyb24gLS0-IEV4cG9QdXNoCiAgTG9jYWxOb3RpZnkgLS0-IEZpcmVzdG9yZQo?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 2: System architecture showing the Expo Android client, Vercel backend, AI provider, Firestore, Google Sheets and Drive, Apps Script, Gmail, and Expo push notifications." /></p>
<p>The system has four storage and integration boundaries:</p>
<ul>
<li>Firestore is the source of truth for a user's jobs and push tokens.</li>
<li>Google Sheets stores the resume index and extracted resume text.</li>
<li>Google Drive stores the original PDFs.</li>
<li>Apps Script owns Gmail delivery and the existing spreadsheet workflow.</li>
</ul>
<p>The mobile app never talks to Anthropic, Google Drive, Gmail, or Apps Script directly. It only talks to the Vercel API.</p>
<h2>The data model: three states, not one</h2>
<p>A job is not a single status. It has three separate state axes:</p>
<pre><code class="language-ts">// PassX-Easeout/src/domain/job.ts

JOB_INTAKE_STATUSES = [
  'unparsed',
  'parsed',
  'needs_review',
  'confirmed',
] as const;

JOB_APPROVAL_STATUSES = [
  'draft',
  'pending_review',
  'approved',
  'rejected',
] as const;

JOB_APPLICATION_STATUSES = [
  'draft',
  'applied',
  'interview',
  'offer',
  'rejected',
  'withdrawn',
  'ghosted',
] as const;
</code></pre>
<p>This separation matters:</p>
<ul>
<li><code>intakeStatus</code> says whether the posting has been understood and confirmed.</li>
<li><code>approvalStatus</code> says whether a human has approved the exact outreach materials.</li>
<li><code>applicationStatus</code> says what happened in the real world after sending.</li>
</ul>
<p>Collapsing these into one status field makes it possible to confuse "the draft looks ready" with "a human approved it" or "the email was sent." Keeping them separate makes each transition explicit.</p>
<p>Jobs live at:</p>
<pre><code class="language-text">users/{uid}/jobs/{jobId}
</code></pre>
<p>The Firestore document ID is stable for the life of the job. It is deliberately not the Google Sheet row number. Sheet rows move; document IDs should not.</p>
<p>The mobile domain model has no React Native, Firebase, or Firestore imports. Firestore timestamps are converted to ISO-8601 strings at the repository boundary. That keeps the domain serializable and testable without a Firebase project.</p>
<p>The repository also reads defensively. Legacy Job Tracker records share the same Firestore collection but use different field names and values such as <code>"No Deadline"</code>. Instead of crashing or silently writing a migration, the boundary coerces unknown values to documented defaults. reads are non-destructive, and the canonical pipeline can coexist with older records.</p>
<h2>Stage 1 and 2: capture and extract</h2>
<p>The user can paste a posting or share text from another Android app. Android <code>ACTION_SEND</code> text is passed into the Smart Intake screen. URLs are treated as text; the app does not fetch or scrape them.</p>
<p><code>POST /api/intake</code> accepts:</p>
<pre><code class="language-json">{
  "rawText": "Senior Backend Engineer at Example Corp...",
  "source": "share_android"
}
</code></pre>
<p>The server authenticates first, then validates the body, then loads configuration. An anonymous request cannot discover whether the AI feature is configured. The source vocabulary is a fixed list: <code>paste</code>, <code>share_android</code>, <code>manual</code>, <code>email</code>, <code>linkedin</code>, <code>x</code>, <code>referral</code>, or <code>other</code>. The model does not choose it.</p>
<p>The model returns candidate fields and confidence values. The server then validates the result:</p>
<ul>
<li>Confidence values must be finite and between 0 and 1.</li>
<li>Dates must be real ISO-8601 dates.</li>
<li>Extracted contact email and job URL must actually appear in the supplied text.</li>
<li>Unknown fields are stripped.</li>
<li>Missing fields become <code>null</code> with confidence <code>0</code>.</li>
</ul>
<p>The raw posting is not echoed back. The client already has it, and returning it would double the response size.</p>
<p>On the mobile side, the review screen highlights low-confidence fields. The current threshold is <code>0.6</code>. The user can edit everything before confirmation. Nothing is saved to the canonical job collection until the user confirms.</p>
<h2>Stage 3: confirm before saving</h2>
<p>The review screen is the handoff from "model output" to "user data." It uses the same deadline parsing and display helpers as the manual job form, so an extracted date and a typed date cannot drift into two different formats.</p>
<p>The user can correct any field, then confirms. Only then is the canonical <code>Job</code> written to Firestore. The raw text remains available for later matching and drafting.</p>
<p>This is the first human checkpoint in the pipeline. It is not the approval gate, but it is the point where the model stops being the authority on what the job is.</p>
<h2>Stage 4: matching resumes with a bounded model call</h2>
<p>Once a job is confirmed, <code>POST /api/match</code> loads the job, loads the resume library, and asks the model to rank resumes.</p>
<p>The matching contract has five criteria:</p>
<table>
<thead>
<tr>
<th>Criterion</th>
<th>What it tries to capture</th>
</tr>
</thead>
<tbody><tr>
<td><code>must_have</code></td>
<td>Required skills and experience</td>
</tr>
<tr>
<td><code>nice_to_have</code></td>
<td>Preferred but non-essential requirements</td>
</tr>
<tr>
<td><code>seniority</code></td>
<td>Level alignment</td>
</tr>
<tr>
<td><code>role_alignment</code></td>
<td>Similarity of the actual job function</td>
</tr>
<tr>
<td><code>technology_alignment</code></td>
<td>Overlap in languages, frameworks, and tools</td>
</tr>
</tbody></table>
<p>Each criterion gets an integer score from 0 to 100, a weight, and an optional rationale. The weights are decided by the server and sum to 1. The model does not get to invent the scoring scheme.</p>
<p>Candidate selection is bounded:</p>
<ul>
<li>The library is sent in full up to 15 resumes.</li>
<li>Resumes without extracted text are excluded.</li>
<li>If there are more than 15 usable resumes, a deterministic lexical pre-filter keeps the top 15 by overlap with the posting.</li>
<li>The pre-filter is a budget mechanism, not the final ranking. The model still decides the order.</li>
</ul>
<p>The server validates every part of the response before writing anything. It rejects:</p>
<ul>
<li>scores outside 0 to 100,</li>
<li>non-integer scores,</li>
<li>unknown or duplicate resume IDs,</li>
<li>empty rankings,</li>
<li>a selected resume that is absent from the rankings,</li>
<li>a selected resume whose score is not the highest,</li>
<li>a <code>matchScore</code> that disagrees with the selected ranking,</li>
<li>malformed JSON or prose around the JSON.</li>
</ul>
<p>On success, the handler writes exactly three fields to the user's job document:</p>
<pre><code class="language-text">selectedResumeId
matchScore
matchBreakdown
</code></pre>
<p>It does not touch the draft, approval status, application status, or sheet mirror fields. Re-running the match replaces only those three fields.</p>
<h2>Stage 5: suggestions that cannot invent experience</h2>
<p><code>POST /api/suggestions</code> takes the job, the selected resume text, and the match breakdown. It returns at most four suggestions.</p>
<p>Each suggestion is evidence-based:</p>
<pre><code class="language-ts">interface ResumeSuggestion {
  id: string;
  priority: 'high' | 'medium' | 'low';
  category: SuggestionCategory;
  actionType: SuggestionActionType;
  title: string;
  suggestion: string;
  jobEvidence: string;
  resumeEvidence: string;
}
</code></pre>
<p>The category and action vocabularies are deliberately constrained to working with material the resume already contains. There is no "add a certification" category, because naming one would make inventing credentials a valid request.</p>
<p>The server's normalizer checks the model's output against both the job text and the resume text. A suggestion that cites a skill or achievement not supported by either source is rejected. An empty suggestion list is valid and is persisted as empty, not as <code>null</code>. "Nothing worth changing" is a legitimate result.</p>
<p>Suggestions are inert. Nothing edits the resume automatically.</p>
<h2>Stage 6: drafting the outreach email</h2>
<p><code>POST /api/draft</code> generates a subject and message from the job, the selected resume, and the match context.</p>
<p>The draft is not treated as trustworthy because a model wrote it. The server has a single source of truth for the text used by its grounding guards, and the normalizer rejects output that introduces unsupported claims. The client then displays the draft for editing.</p>
<p>The approved content is stored on the job as:</p>
<pre><code class="language-text">draftSubject
draftMessage
</code></pre>
<p>These are the exact strings a human will later approve and the exact strings the send path will later require.</p>
<h2>Stage 7: approval is a fact about specific bytes</h2>
<p>The approval endpoint is where the human gate becomes real.</p>
<p><code>POST /api/approval</code> accepts only:</p>
<pre><code class="language-json">{ "jobId": "abc123", "action": "approve" }
</code></pre>
<p>The supported actions are <code>review</code>, <code>approve</code>, <code>reject</code>, and <code>reopen</code>. Everything else in the body is ignored. A caller cannot send <code>approvalStatus</code>, <code>approvedAt</code>, <code>approvedBy</code>, <code>applicationStatus</code>, or another user's <code>uid</code> and have it accepted.</p>
<p>The state machine is explicit:</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Allowed from</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>review</code></td>
<td><code>draft</code>, <code>rejected</code></td>
<td><code>pending_review</code></td>
</tr>
<tr>
<td><code>approve</code></td>
<td><code>pending_review</code></td>
<td><code>approved</code></td>
</tr>
<tr>
<td><code>reject</code></td>
<td><code>pending_review</code></td>
<td><code>rejected</code></td>
</tr>
<tr>
<td><code>reopen</code></td>
<td><code>approved</code>, <code>rejected</code></td>
<td><code>pending_review</code></td>
</tr>
</tbody></table>
<p>Approving from <code>draft</code> returns a conflict. Approving a rejected application requires reopening first. Re-approving an already approved job returns success without restamping the approval timestamp.</p>
<p>Before entering review, the server checks that the job is confirmed, has a selected resume, has a match result, and has a draft. Rejection is intentionally exempt from those preconditions. A human must always be able to stop a broken application, even if the draft is missing.</p>
<p>The server writes exactly three approval fields:</p>
<pre><code class="language-text">approvalStatus
approvedAt
approvedBy
</code></pre>
<p>It does not change the application lifecycle, the match, the draft, or the resume.</p>
<h3>Approval is revoked when the materials change</h3>
<p>A human approved <em>this</em> subject, <em>this</em> message, and <em>this</em> resume. If any of them changes afterward, the previous approval no longer describes what would be sent.</p>
<p>The server therefore treats these fields as approval-invalidating:</p>
<pre><code class="language-text">draftSubject
draftMessage
selectedResumeId
matchScore
matchBreakdown
resumeSuggestions
</code></pre>
<p>Content writes run through a Firestore transaction. If the stored status is <code>approved</code> and a material field changes, the transaction applies a revocation patch. This closes the race where an approval and a content update happen at nearly the same time.</p>
<p>That is the difference between "the UI normally clears the approval" and "the stored approval cannot survive a material change."</p>
<h2>Stage 8: sending, with revalidation immediately before the side effect</h2>
<p><code>POST /api/send</code> is the only route that can cause an email to leave the system. It is also the most defensive.</p>
<p>The client sends only a job ID. The server:</p>
<ol>
<li>Authenticates the caller and derives the user ID from the token.</li>
<li>Loads the job from <code>users/{uid}/jobs/{jobId}</code>.</li>
<li>Rejects non-canonical or missing jobs with the same <code>404</code>.</li>
<li>Requires <code>intakeStatus === 'confirmed'</code>.</li>
<li>Requires an approved status, a contact email, a company and role, a selected resume, a match, and a non-empty draft.</li>
<li>Resolves the selected resume against the private resume library.</li>
<li>Requires the resume to have a server-side Drive file ID.</li>
<li>Rejects jobs that already have <code>emailSentOn</code>, <code>gmailThreadId</code>, or <code>applicationStatus === 'applied'</code>.</li>
<li>Creates an expiring send claim to serialize concurrent requests.</li>
<li>Re-reads the job after claiming it.</li>
<li>Compares the approval material with the material recorded before the claim.</li>
<li>Rechecks approval immediately before calling the external bridge.</li>
<li>Sends only the approved subject and message, plus the resolved Drive file ID.</li>
<li>Confirms the result through the Apps Script <code>jobs</code> action and requires a persisted <code>Sent</code> row.</li>
<li>Persists <code>applicationStatus</code>, <code>emailSentOn</code>, and <code>gmailThreadId</code>.</li>
</ol>
<p><img src="https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgYXV0b251bWJlcgogIHBhcnRpY2lwYW50IEFwcCBhcyBBbmRyb2lkIGFwcAogIHBhcnRpY2lwYW50IEFQSSBhcyAvYXBpL3NlbmQKICBwYXJ0aWNpcGFudCBGUyBhcyBGaXJlc3RvcmUKICBwYXJ0aWNpcGFudCBMaWIgYXMgUmVzdW1lIGxpYnJhcnkKICBwYXJ0aWNpcGFudCBBUyBhcyBBcHBzIFNjcmlwdAogIHBhcnRpY2lwYW50IEdNIGFzIEdtYWlsCgogIEFwcC0-PkFQSTogUE9TVCB7IGpvYklkIH0KICBBUEktPj5BUEk6IFZlcmlmeSBGaXJlYmFzZSBJRCB0b2tlbgogIEFQSS0-PkZTOiBMb2FkIGpvYiB1bmRlciB2ZXJpZmllZCB1aWQKICBBUEktPj5BUEk6IENoZWNrIGFwcHJvdmFsIGFuZCBzZW5kIHByZWNvbmRpdGlvbnMKICBBUEktPj5MaWI6IFJlc29sdmUgc2VsZWN0ZWRSZXN1bWVJZAogIExpYi0tPj5BUEk6IFByaXZhdGUgRHJpdmUgZmlsZSBJRAogIEFQSS0-PkZTOiBDcmVhdGUgZXhwaXJpbmcgc2VuZCBjbGFpbQogIEFQSS0-PkZTOiBSZS1yZWFkIGpvYiBhbmQgY29tcGFyZSBhcHByb3ZhbCBtYXRlcmlhbAogIEFQSS0-PkFQSTogUmVjaGVjayBhcHByb3ZhbCBuZXh0IHRvIHNpZGUgZWZmZWN0CiAgQVBJLT4-QVM6IHNlbmQgYXBwcm92ZWQgc3ViamVjdCwgbWVzc2FnZSwgcmVzdW1lIGZpbGUKICBBUy0-PkdNOiBTZW5kIGVtYWlsCiAgQVMtLT4-QVBJOiBSZXN1bHQKICBBUEktPj5BUzogam9icyBhY3Rpb24KICBBUy0tPj5BUEk6IENvbmZpcm0gU2VudCByb3cKICBBUEktPj5GUzogUGVyc2lzdCBhcHBsaWVkLCBlbWFpbFNlbnRPbiwgZ21haWxUaHJlYWRJZAogIEFQSS0tPj5BcHA6IFNlbmQgcmVzdWx0Cg?type=png&amp;theme=dark&amp;bgColor=111827" alt="Figure 3: Sequence diagram for sending an approved application, including authentication, Firestore revalidation, resume resolution, send claim, Apps Script/Gmail delivery, confirmation, and persistence." /></p>
<p>The claim is important. Two simultaneous requests must not both reach Gmail. If the Apps Script call times out after the mail was actually sent, a retry is stopped by Apps Script's duplicate confirmation rather than creating a second application.</p>
<p>The send path also resolves the selected resume on the server. The mobile app never receives the Drive file ID. The client cannot swap in a different attachment after approval.</p>
<h2>The backend is intentionally boring</h2>
<p>The serverless constraints shaped the design:</p>
<ul>
<li>Vercel functions have no durable process memory.</li>
<li>The filesystem is not durable between invocations.</li>
<li>Cold starts are real.</li>
<li>Most AI routes have a 30-second ceiling.</li>
<li>The reminder cron runs once per day at 09:00 UTC with a 60-second ceiling.</li>
</ul>
<p>So the backend stays stateless. There is no in-process vector index or local embedding model. A module-level cache would be a performance optimization at best, never a correctness dependency.</p>
<p>Every route is built around dependency injection. Handlers export a <code>createHandler(overrides)</code> factory, and tests inject a token verifier, a Firestore double, a Sheet client, a model response, and a clock. That is why the test suite can exercise the full approval and send paths without contacting Firebase, Google, Anthropic, or Gmail.</p>
<p>Configuration fails closed. Missing credentials produce a controlled "not configured" response after authentication, not a stack trace and not a configuration dump. Provider errors are logged server-side and replaced with fixed client messages because model output can quote the job posting, and SDK errors can contain request URLs or account identifiers.</p>
<h2>The mobile client is a thin, typed boundary</h2>
<p>The mobile app has one HTTP client, <code>src/api/client.ts</code>. It handles:</p>
<ul>
<li>base URL resolution,</li>
<li>bearer token attachment,</li>
<li>JSON parsing,</li>
<li>timeout handling,</li>
<li>cancellation,</li>
<li>network, timeout, auth, and malformed-response errors.</li>
</ul>
<p>The API modules validate response envelopes instead of trusting them. A server that returns an older schema version is treated as incompatible. Unknown fields are dropped. This keeps a newer backend from putting unlabelled data in front of an older client.</p>
<p>The Firestore repository is the only place that knows about Firestore. It converts timestamps, validates enums, strips approval fields from content patches, and runs approval revocation inside transactions. The domain layer does not know Firestore exists.</p>
<p>That separation is not architectural decoration. It is what makes it possible to test the pipeline, reason about state transitions, and change the persistence layer without rewriting the UI.</p>
<h2>Reminders and Android sharing</h2>
<p>The reminder path is separate from the AI pipeline. A Vercel cron calls <code>POST /api/reminders/run</code> with a bearer <code>CRON_SECRET</code>. The route uses Firebase Admin credentials to find deadline and follow-up events, creates deterministic per-user event claims in Firestore to deduplicate repeat runs, and delivers through Expo push. Telegram is optional and requires all three Telegram variables together.</p>
<p>The mobile app also schedules stable local notifications for deadlines and follow-ups and cancels them when a deadline is removed or a job is archived. Reminders only notify. They never send a follow-up email.</p>
<p>Android sharing is intentionally narrow: shared text is passed into Smart Intake for review. URLs are not fetched or scraped.</p>
<h2>What I verified, and what I did not</h2>
<p>I ran the offline checks while preparing this article:</p>
<pre><code class="language-bash"># Backend
cd jobemailer
npm test

# Mobile
cd PassX-Easeout
npx jest --runInBand
npx tsc --noEmit
</code></pre>
<p>Results:</p>
<ul>
<li>Backend: 568 tests passed, 0 failed.</li>
<li>Mobile: 499 tests passed across 24 suites, 0 failed.</li>
<li>Mobile TypeScript: clean, exit 0.</li>
</ul>
<p>Those tests are hermetic. They use injected fakes and do not contact Firebase, Google, Anthropic, Gmail, Apps Script, Expo, or Telegram.</p>
<p>What I did not verify for this article:</p>
<ul>
<li>A live Firebase sign-in and Firestore read/write.</li>
<li>A live Anthropic extraction, match, suggestion, or draft.</li>
<li>A real Google Sheets/Drive resume sync.</li>
<li>A real Apps Script deployment and Gmail send.</li>
<li>A production Android build with real Firebase configuration.</li>
<li>Vercel deployment state and remote environment variables.</li>
</ul>
<p>So the correct description is: implemented in code, offline-tested, and not live-verified end to end. That distinction belongs in the article, not in a footnote.</p>
<h2>Setup outline</h2>
<p>The backend needs, at minimum:</p>
<pre><code class="language-text">APPS_SCRIPT_URL
APPS_SCRIPT_TOKEN
GOOGLE_SERVICE_ACCOUNT_JSON
GOOGLE_RESUME_FOLDER_ID
GOOGLE_RESUME_SHEET_ID
ANTHROPIC_API_KEY
CRON_SECRET
</code></pre>
<p>Alternatively, Firebase Admin can use the split credential variables:</p>
<pre><code class="language-text">FIREBASE_ADMIN_PROJECT_ID
FIREBASE_ADMIN_CLIENT_EMAIL
FIREBASE_ADMIN_PRIVATE_KEY
</code></pre>
<p>The Apps Script project needs a <code>DASHBOARD_API_TOKEN</code> script property with the same value as <code>APPS_SCRIPT_TOKEN</code>, and it must be deployed as a web app with access set to "Anyone."</p>
<p>The mobile app needs:</p>
<pre><code class="language-text">EXPO_PUBLIC_JOBEMAILER_API_URL
EXPO_PUBLIC_EAS_PROJECT_ID
</code></pre>
<p>The app also needs a real <code>android/app/google-services.json</code> for package <code>com.ease.out</code>. It cannot run in Expo Go because of the native Firebase modules.</p>
<p>Do not put server secrets in the mobile bundle. <code>EXPO_PUBLIC_*</code> values are public by definition.</p>
<h2>What I would change next</h2>
<h3>1. Per-user resume scoping</h3>
<p>The current resume library is a shared Google Sheet. It is effectively single-candidate, and any authenticated caller can read the same resume list. Before this becomes a multi-user product, the library needs real per-user scoping, or the API needs to enforce ownership at the resume level.</p>
<h3>2. Retrieval only after measurement</h3>
<p>There is a written RAG proposal in the repository, but it is not implemented. That is the right order. The current match path already sends a bounded set of resumes in one model call. Adding embeddings would introduce cold-start latency, index persistence problems, and another failure surface. Retrieval should be driven by measured retrieval quality, not by the fact that RAG is fashionable.</p>
<h3>3. Commit Firestore rules</h3>
<p>The mobile README documents that Firestore rules are managed in the Firebase console and are not in the repository. That is not a safe production posture. Rules should be versioned, reviewed, and tested like application code.</p>
<h3>4. Clean up stale documentation and legacy screens</h3>
<p>The codebase moved faster than parts of its documentation. For example, the canonical Job domain file still contains a comment saying the model is not adopted by any screen, while the main pipeline and intake review screens already use it. The legacy Job Tracker screen still reads and writes a different shape in the same collection.</p>
<p>That is tolerable during a migration, but it is dangerous in an article about correctness. The next step is to make the documentation match the code and retire or isolate the legacy path.</p>
<h3>5. Finish the release path</h3>
<p>The Android release build still needs proper signing. The debug configuration is not a shipping configuration. Real Firebase configuration, an EAS project ID, and a production keystore are operational prerequisites, not afterthoughts.</p>
<h2>The lesson: separate generation from authority</h2>
<p>The most useful idea in this project is not the prompt. It is the authority model.</p>
<p>The LLM is allowed to:</p>
<ul>
<li>propose structured fields,</li>
<li>rank resumes,</li>
<li>suggest improvements,</li>
<li>draft a message.</li>
</ul>
<p>It is not allowed to:</p>
<ul>
<li>decide that a job is confirmed,</li>
<li>approve an application,</li>
<li>mark an application as sent,</li>
<li>choose a different resume after approval,</li>
<li>write arbitrary fields to Firestore,</li>
<li>call Gmail directly.</li>
</ul>
<p>Those decisions belong to deterministic server code and, at the approval gate, to a human.</p>
<p>That separation makes the system slower than a one-click auto-applier. It also makes it something I would actually connect to a real inbox.</p>
<p>If you are building with LLMs in a workflow that has real-world side effects, the useful question is not "How do I make the model more autonomous?" It is "Where is the smallest, most explicit gate I can put between the model's output and the irreversible action?"</p>
<p>For a job application, that gate is a human reading the exact email that will be sent.</p>
<h2>Code map</h2>
<p>If you want to follow the implementation:</p>
<ul>
<li><code>PassX-Easeout/src/domain/job.ts</code>: canonical job model and state vocabularies.</li>
<li><code>PassX-Easeout/src/data/jobs/firestore.ts</code>: Firestore repository, normalization, approval revocation.</li>
<li><code>PassX-Easeout/src/api/*.ts</code>: typed HTTP clients and response validation.</li>
<li><code>PassX-Easeout/src/screens/JobIntakeScreen.tsx</code>: capture flow.</li>
<li><code>PassX-Easeout/src/screens/JobIntakeReviewScreen.tsx</code>: extraction review and confirmation.</li>
<li><code>PassX-Easeout/src/screens/JobPipelineScreen.tsx</code>: main pipeline orchestration.</li>
<li><code>PassX-Easeout/src/components/jobs/</code>: match, suggestions, draft, approval, and job cards.</li>
<li><code>jobemailer/api/*.js</code>: serverless route handlers.</li>
<li><code>jobemailer/lib/approval.js</code>: approval transition table and approval material.</li>
<li><code>jobemailer/lib/send.js</code>: claim, revalidate, send, confirm, persist.</li>
<li><code>jobemailer/lib/claude.js</code>: the single model provider boundary.</li>
<li><code>jobemailer/lib/jobs.js</code>: transactional job writes and ownership paths.</li>
<li><code>jobemailer/apps-script/Code.gs</code>: Gmail/Sheets bridge.</li>
<li><code>jobemailer/test/</code>: backend tests with injected dependencies.</li>
</ul>
<p>The code is deliberately unglamorous in the places that matter: authentication, validation, state transitions, idempotency, and the final send. That is where an AI workflow earns the right to touch a real inbox.</p>
]]></content:encoded></item><item><title><![CDATA[Calibration Is Not Accuracy: Building a Reproducible Decision-Policy Study for Chest X-Rays]]></title><description><![CDATA[chest X-ray study separates ranking from deciding: frozen DenseNet121, validation-only calibration, thresholds, and patient-level statistics.]]></description><link>https://vishalkumarojha.hashnode.dev/calibration-is-not-accuracy-building-a-reproducible-decision-policy-study-for-chest-x-rays</link><guid isPermaLink="true">https://vishalkumarojha.hashnode.dev/calibration-is-not-accuracy-building-a-reproducible-decision-policy-study-for-chest-x-rays</guid><category><![CDATA[`machine-learning`, `healthcare`, `pytorch`, `calibration`, `research`]]></category><dc:creator><![CDATA[Vishal Ojha]]></dc:creator><pubDate>Sun, 01 Nov 2026 22:49:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66fc3f0d2d6a5ab57529b5fe/a9dd48bc-7603-4222-9c36-ea33ea86f986.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A chest X-ray classifier can be excellent at ranking images and still be a poor decision tool. An AUROC of 0.89 says the model usually assigns higher scores to positive cases than to negative ones. It does not say that a score of 0.90 means a 90% chance of disease. It also does not say that 0.50 is the right cutoff for a screening workflow, a referral workflow, or a research comparison.</p>
<p>This article walks through a repository that treats those two problems — <strong>probability calibration</strong> and <strong>decision-threshold selection</strong> — as first-class engineering artifacts around a frozen DenseNet121 model. The project does not train a new architecture or claim a new calibration algorithm. Its contribution is a controlled, reproducible study: one fixed checkpoint, one cached prediction table, validation-only parameter fitting, held-out evaluation, patient-level statistical inference, error analysis, Grad-CAM, and a read-only dashboard built on top of the verified evidence.</p>
<blockquote>
<p>This is a research and engineering case study. It is not a clinically validated system, and nothing in the repository should be used for diagnosis or treatment decisions.</p>
</blockquote>
<h2>The problem: ranking is not deciding</h2>
<p>The dataset is NIH ChestX-ray14, restricted to two findings: <strong>Cardiomegaly</strong> and <strong>Effusion</strong>. The task is multi-label binary classification. The model is a torchvision DenseNet121 with an ImageNet initialization and a two-output linear head. Training uses <code>BCEWithLogitsLoss</code> with positive-class weights derived from the training prevalence (<code>[39.945, 7.552]</code>), because both findings are rare relative to the full cohort.</p>
<p>That weighting is useful for learning from imbalanced data, but it has a side effect: it pushes predicted probabilities upward. A model can rank well while being badly over-confident. The repository measures this directly:</p>
<table>
<thead>
<tr>
<th>Test metric</th>
<th align="right">Cardiomegaly</th>
<th align="right">Effusion</th>
</tr>
</thead>
<tbody><tr>
<td>Prevalence</td>
<td align="right">2.6%</td>
<td align="right">12.6%</td>
</tr>
<tr>
<td>AUROC</td>
<td align="right">0.8972</td>
<td align="right">0.8587</td>
</tr>
<tr>
<td>AUPRC</td>
<td align="right">0.3043</td>
<td align="right">0.4700</td>
</tr>
<tr>
<td>F1 at threshold 0.50</td>
<td align="right">0.2836</td>
<td align="right">0.4676</td>
</tr>
<tr>
<td>Expected calibration error (ECE, 15 bins)</td>
<td align="right">0.1061</td>
<td align="right">0.2077</td>
</tr>
</tbody></table>
<p>The ranking metrics are respectable. The operating point is not obviously appropriate. The project therefore asks two separate questions:</p>
<ol>
<li><strong>Calibration:</strong> are the raw sigmoid probabilities meaningful as probabilities?</li>
<li><strong>Thresholds:</strong> which per-label operating point should be used, and how does it transfer from validation to test?</li>
</ol>
<p>A third question — external generalization — is part of the design but remains unanswered because no external cohort is present in the repository.</p>
<h3>Who this is for</h3>
<p>The project is aimed at machine-learning engineers, applied researchers, and students who want to see how a model-evaluation study is assembled as a reproducible software system. It is not a clinical product, and the dashboard is a demonstration layer, not a medical device.</p>
<h2>What the project actually does</h2>
<p>The repository implements a complete research pipeline around a <strong>frozen control model</strong>:</p>
<table>
<thead>
<tr>
<th>Capability</th>
<th>Status</th>
<th>Where it lives</th>
</tr>
</thead>
<tbody><tr>
<td>Frozen DenseNet121 baseline</td>
<td>Implemented and verified</td>
<td><code>src/model.py</code>, <code>src/train.py</code>, <code>outputs/checkpoints/baseline/</code></td>
</tr>
<tr>
<td>Cached logits and probabilities</td>
<td>Implemented and verified</td>
<td><code>src/inference.py</code>, <code>outputs/predictions/raw/</code></td>
</tr>
<tr>
<td>Baseline metrics at threshold 0.50</td>
<td>Implemented and verified</td>
<td><code>src/baseline_eval.py</code>, <code>src/metrics.py</code></td>
</tr>
<tr>
<td>Per-label temperature scaling</td>
<td>Implemented and verified</td>
<td><code>src/calibration.py</code>, <code>src/fit_parameters.py</code></td>
</tr>
<tr>
<td>Five validation-fitted threshold policies</td>
<td>Implemented and verified</td>
<td><code>src/thresholds.py</code></td>
</tr>
<tr>
<td>A/B/C/D calibration-and-threshold decomposition</td>
<td>Implemented and verified</td>
<td><code>src/experiments.py</code>, <code>outputs/final_results/</code></td>
</tr>
<tr>
<td>Patient-level bootstrap, permutation tests, DeLong CIs</td>
<td>Implemented and verified</td>
<td><code>src/statistics/</code>, <code>outputs/final_results/</code></td>
</tr>
<tr>
<td>Error analysis and deterministic Grad-CAM cases</td>
<td>Implemented and verified</td>
<td><code>src/error_analysis.py</code>, <code>src/gradcam.py</code></td>
</tr>
<tr>
<td>External CheXpert evaluation</td>
<td>Pipeline implemented; <strong>blocked on data</strong></td>
<td><code>src/external_eval.py</code></td>
</tr>
<tr>
<td>Read-only Gradio research dashboard</td>
<td>Implemented</td>
<td><code>research_demo_app.py</code>, <code>src/ui_data.py</code></td>
</tr>
<tr>
<td>Paper-ready evidence package</td>
<td>Implemented and verified</td>
<td><code>src/paper_artifacts.py</code>, <code>src/paper_build.py</code>, <code>outputs/paper/</code></td>
</tr>
</tbody></table>
<p>The most important design choice is that the model is <strong>not</strong> the object of study. The checkpoint is frozen and hash-anchored. What changes between experiments is only how the model's logits are calibrated and thresholded. That makes the comparison a genuine decomposition rather than a sequence of unrelated model runs.</p>
<h2>Technology stack and engineering choices</h2>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Role in the project</th>
<th>Why it is appropriate</th>
<th>Evidence</th>
</tr>
</thead>
<tbody><tr>
<td>Python 3.14.7</td>
<td>Runtime for training, evaluation, and analysis</td>
<td>The project is a research pipeline built around scientific Python and PyTorch</td>
<td><code>docs/TECH_STACK.md</code>, <code>outputs/research_snapshot.json</code></td>
</tr>
<tr>
<td>PyTorch 2.14.0 + CUDA 13</td>
<td>Model inference, AMP, Grad-CAM backward pass</td>
<td>DenseNet121 and Grad-CAM are naturally expressed in PyTorch</td>
<td><code>requirements.txt</code>, <code>src/model.py</code>, <code>src/gradcam.py</code></td>
</tr>
<tr>
<td>torchvision 0.29.0</td>
<td>DenseNet121 with ImageNet weights</td>
<td>A standard, reproducible backbone with pretrained weights</td>
<td><code>src/model.py</code></td>
</tr>
<tr>
<td>NumPy 2.5.3 + pandas 3.0.5</td>
<td>Prediction tables, metric arrays, result CSVs</td>
<td>The pipeline is columnar and file-based; pandas is the natural tabular layer</td>
<td><code>src/inference.py</code>, <code>src/statistics/</code></td>
</tr>
<tr>
<td>scikit-learn 1.9.0</td>
<td>AUROC, AUPRC, ROC/PR curves</td>
<td>Reference implementations keep metric code small and testable</td>
<td><code>src/metrics.py</code></td>
</tr>
<tr>
<td>SciPy 1.18.1</td>
<td>Bounded temperature optimization, DeLong normal quantiles</td>
<td>One-dimensional bounded optimization is exactly SciPy's job</td>
<td><code>src/calibration.py</code>, <code>src/statistics/delong.py</code></td>
</tr>
<tr>
<td>Albumentations 2.0.8</td>
<td>Evaluation and training transforms</td>
<td>Explicit image pipeline with a clean evaluation-only path</td>
<td><code>src/dataset.py</code></td>
</tr>
<tr>
<td>matplotlib 3.11.1 (Agg)</td>
<td>Reliability diagrams, threshold sweeps, error plots</td>
<td>Deterministic, headless figure generation</td>
<td><code>src/plots.py</code></td>
</tr>
<tr>
<td>Gradio 6.x</td>
<td>Faculty-facing dashboard</td>
<td>Fast local UI over verified artifacts, with no frontend build step</td>
<td><code>research_demo_app.py</code>, <code>requirements_demo.txt</code></td>
</tr>
<tr>
<td><code>unittest</code></td>
<td>Test suite</td>
<td>Standard library, no extra dependency; the project deliberately avoids pytest</td>
<td><code>tests/</code>, <code>docs/TECH_STACK.md</code></td>
</tr>
</tbody></table>
<p>The stack is intentionally conservative. There is no experiment tracker, no database, no web framework, and no CI configuration. The repository uses JSON as the canonical machine-readable result format, CSV as a tidy projection, and PNGs as derived views. That choice makes the evidence easy to inspect, diff, and test without standing up additional infrastructure.</p>
<h2>Project structure</h2>
<pre><code class="language-text">Capstone/
├── src/
│   ├── config.py                 single source of paths, labels, splits, settings
│   ├── dataset.py                NIH data loading, transforms, dataloaders
│   ├── model.py                  DenseNet121 encoder + two-label classifier
│   ├── train.py                  frozen baseline training recipe
│   ├── evaluate.py               original evaluation entry point
│   ├── inference.py              cached logits/probabilities per split
│   ├── metrics.py                AUROC, AUPRC, confusion metrics
│   ├── calibration.py            ECE, Brier, NLL, temperature scaling
│   ├── thresholds.py             five decision-threshold policies
│   ├── fit_parameters.py         fit and freeze validation-only parameters
│   ├── experiments.py            experiments 1-4
│   ├── error_analysis.py         strata, high-confidence errors, case list
│   ├── gradcam.py                Grad-CAM overlays and bbox cohort
│   ├── external_eval.py          CheXpert pipeline (blocked on data)
│   ├── statistics/               patient bootstrap, DeLong, permutation tests
│   ├── research_freeze.py        freeze record
│   ├── master_results.py         canonical master table
│   ├── uncertainty.py            bootstrap confidence intervals
│   ├── paper_artifacts.py        tables, figures, exports, summary
│   └── paper_build.py            evidence map and paper package
├── research_demo_app.py          read-only Gradio dashboard + live inference
├── demo_app.py                   older multi-modality demo (legacy)
├── tests/                        unittest suite
├── data/                         raw and processed data (git-ignored)
├── outputs/                      checkpoints, predictions, metrics, figures, paper
└── docs/                         scope, baseline, experiments, reproducibility
</code></pre>
<p>The legacy ECHO, MRI, and fusion modules are kept in the repository but are explicitly out of scope for the X-ray research claims. The README labels them <code>LEGACY</code>, and the frozen X-ray baseline does not depend on them. The root-level <code>demo_app.py</code> remains for the older four-modality demonstration; the research dashboard is <code>research_demo_app.py</code>.</p>
<h2>System architecture</h2>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRCCiAgICBzdWJncmFwaCBEYXRhWyJEYXRhIGxheWVyIl0KICAgICAgICBOSUhbIk5JSCBDaGVzdFgtcmF5MTQ8YnIvPjEwOSwzMTIgaW1hZ2VzIC8gMjksNzIwIHBhdGllbnRzIl0KICAgICAgICBTUExJVFsiRnJvemVuIHBhdGllbnQtbGV2ZWwgc3BsaXQ8YnIvPjcwLzE1LzE1LCBzZWVkIDQyIl0KICAgICAgICBNRVRBWyJEYXRhX0VudHJ5XzIwMTcuY3N2PGJyLz5CQm94X0xpc3RfMjAxNy5jc3YiXQogICAgZW5kCgogICAgc3ViZ3JhcGggTW9kZWxbIkZyb3plbiBtb2RlbCBsYXllciJdCiAgICAgICAgQ0tQVFsiRGVuc2VOZXQxMjEgY2hlY2twb2ludDxici8-c2hhMjU2IDM1OTY1ZjYxMGM4Yi4uLiJdCiAgICAgICAgSU5GWyJzcmMuaW5mZXJlbmNlPGJyLz5jYWNoZWQgbG9naXRzICsgcHJvYmFiaWxpdGllcyJdCiAgICBlbmQKCiAgICBzdWJncmFwaCBSZXNlYXJjaFsiVmFsaWRhdGlvbi1vbmx5IHJlc2VhcmNoIGxheWVyIl0KICAgICAgICBURU1QWyJQZXItbGFiZWwgdGVtcGVyYXR1cmUgc2NhbGluZzxici8-VCA9IDEuMTg2NSAvIDEuMzk3OSJdCiAgICAgICAgVEhSWyJGaXZlIHRocmVzaG9sZCBwb2xpY2llczxici8-Zml4ZWQsIEYxLCBZb3VkZW4sIHNlbnMsIHByZWNpc2lvbiJdCiAgICAgICAgRVhQWyJFeHBlcmltZW50cyAxLTQ8YnIvPkEvQi9DL0QgZGVjb21wb3NpdGlvbiJdCiAgICAgICAgU1RBVFNbIlN0YXRpc3RpY3M8YnIvPnBhdGllbnQgYm9vdHN0cmFwLCBEZUxvbmcsIHBlcm11dGF0aW9uIl0KICAgICAgICBFUlJbIkVycm9yIGFuYWx5c2lzICsgR3JhZC1DQU0iXQogICAgZW5kCgogICAgc3ViZ3JhcGggRXZpZGVuY2VbIkV2aWRlbmNlIGFuZCBwcmVzZW50YXRpb24gbGF5ZXIiXQogICAgICAgIEFSVFsib3V0cHV0cy8gSlNPTiwgQ1NWLCBQTkcgYXJ0aWZhY3RzIl0KICAgICAgICBQQVBFUlsiUGFwZXIgcGFja2FnZTxici8-dGFibGVzLCBmaWd1cmVzLCBjbGFpbSBhdWRpdCJdCiAgICAgICAgVUlbIkdyYWRpbyBkYXNoYm9hcmQ8YnIvPnJlYWQtb25seSBldmlkZW5jZSArIGxpdmUgaW5mZXJlbmNlIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsWyJFeHRlcm5hbCB2YWxpZGF0aW9uIChwZW5kaW5nKSJdCiAgICAgICAgQ0hFWFsiQ2hlWHBlcnQgbG9hZGVyPGJyLz5ubyBleHRlcm5hbCBjb2hvcnQgcHJlc2VudCJdCiAgICBlbmQKCiAgICBOSUggLS0-IFNQTElUCiAgICBNRVRBIC0tPiBTUExJVAogICAgU1BMSVQgLS0-IElORgogICAgQ0tQVCAtLT4gSU5GCiAgICBJTkYgLS0-IFRFTVAKICAgIElORiAtLT4gVEhSCiAgICBURU1QIC0tPiBFWFAKICAgIFRIUiAtLT4gRVhQCiAgICBFWFAgLS0-IFNUQVRTCiAgICBFWFAgLS0-IEVSUgogICAgU1RBVFMgLS0-IEFSVAogICAgRVJSIC0tPiBBUlQKICAgIEFSVCAtLT4gUEFQRVIKICAgIEFSVCAtLT4gVUkKICAgIENLUFQgLS0-IFVJCiAgICBDSEVYIC0uLT58YmxvY2tlZCB1bnRpbCBkYXRhIGV4aXN0c3wgQVJU?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="System architecture showing the frozen NIH dataset split, DenseNet121 inference, validation-only calibration and threshold research, statistics, evidence artifacts, dashboard, and pending external validation." /></p>
<p>The architecture separates five concerns:</p>
<ol>
<li><strong>Data and split management.</strong> The NIH metadata is joined to images on disk, and the split is frozen to <code>data/processed/split_index.csv</code>. The split is patient-level, not image-level, so images from one patient do not appear in multiple splits.</li>
<li><strong>Frozen model and inference.</strong> <code>src/inference.py</code> loads the checkpoint, applies the evaluation transform, and writes one cached prediction table per split. The cache key includes the checkpoint SHA-256 prefix, so a different model cannot silently reuse an old model's predictions.</li>
<li><strong>Validation-only parameter fitting.</strong> Temperature scalers and threshold policies are fitted on validation predictions and written to JSON. The runtime guards refuse to fit on <code>test</code>, <code>external</code>, or <code>chexpert</code> splits.</li>
<li><strong>Evaluation and statistics.</strong> Experiments read the cached table and the frozen parameters. They never retrain the model. Statistical inference is a separate layer over the same frozen predictions.</li>
<li><strong>Evidence and presentation.</strong> Results are written as JSON, CSV, and figures. The paper package and the Gradio dashboard are both consumers of those artifacts; neither is allowed to become a second source of truth.</li>
</ol>
<p>The important boundary is between <strong>parameters</strong> and <strong>results</strong>. A temperature or threshold is a parameter: it is fitted on validation and then frozen. An AUROC or F1 on test is a result: it is measured once and never used to tune anything. This is enforced in code, not just documented.</p>
<h2>How the application works</h2>
<p>There are two workflows worth understanding: the offline research pipeline and the live dashboard inference path.</p>
<h3>Offline research workflow</h3>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBWyJGcm96ZW4gY2hlY2twb2ludCArIGZyb3plbiBzcGxpdCBpbmRleCJdIC0tPiBCWyJDYWNoZSByYXcgbG9naXRzIGFuZCBwcm9iYWJpbGl0aWVzPGJyLz5vbmUgQ1NWIHBlciBzcGxpdCJdCiAgICBCIC0tPiBDWyJFdmFsdWF0ZSBiYXNlbGluZSBhdCB0YXUgPSAwLjUwIl0KICAgIEIgLS0-IERbIkZpdCB0ZW1wZXJhdHVyZSBvbiB2YWxpZGF0aW9uIG9ubHkiXQogICAgRCAtLT4gRVsiRml0IHRocmVzaG9sZHMgb24gdmFsaWRhdGlvbiBvbmx5PGJyLz5yYXcgYW5kIGNhbGlicmF0ZWQgcHJvYmFiaWxpdHkgc3BhY2VzIl0KICAgIEMgLS0-IEZbIkV4cGVyaW1lbnRzIDEtNCJdCiAgICBFIC0tPiBGCiAgICBGIC0tPiBHWyJBL0IvQy9EIHRlc3Qgb3BlcmF0aW5nIHBvaW50cyJdCiAgICBHIC0tPiBIWyJQYXRpZW50LWxldmVsIGJvb3RzdHJhcDxici8-cGVybXV0YXRpb24gKyBIb2xtICsgRGVMb25nIl0KICAgIEcgLS0-IElbIkVycm9yIGFuYWx5c2lzPGJyLz5kZXRlcm1pbmlzdGljIEdyYWQtQ0FNIGNhc2UgbGlzdCJdCiAgICBIIC0tPiBKWyJWZXJpZmljYXRpb24gYW5kIHBhcGVyIGFydGlmYWN0cyJdCiAgICBJIC0tPiBKCiAgICBKIC0tPiBLWyJHcmFkaW8gZGFzaGJvYXJkIGFuZCBwdWJsaWNhdGlvbiJdCiAgICBFIC0tPiBReyJFeHRlcm5hbCBjb2hvcnQgYXZhaWxhYmxlPyJ9CiAgICBRIC0tPnxZZXN8IFJbIkZyb3plbiBleHRlcm5hbCBldmFsdWF0aW9uPGJyLz5ubyByZWZpdHRpbmciXQogICAgUSAtLT58Tm98IFNbInN0YXR1cy5qc29uPGJyLz5ub19tZXRyaWNzX3dlcmVfY29tcHV0ZWQ6IHRydWUiXQogICAgUiAtLT4gSgogICAgUyAtLT4gSg?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="Offline research workflow from a frozen checkpoint and split through cached predictions, validation-only fitting, held-out evaluation, statistical analysis, error analysis, and paper artifacts." /></p>
<p>The steps in prose:</p>
<ol>
<li><strong>Freeze the control.</strong> The DenseNet121 checkpoint is copied into <code>outputs/checkpoints/baseline/</code>, hashed, and recorded in a manifest. The split index is frozen to CSV. The model is never retrained during the research phases.</li>
<li><strong>Evaluate the baseline.</strong> <code>src.baseline_eval</code> reads the cached predictions and computes AUROC, AUPRC, sensitivity, specificity, precision, F1, and confusion counts at threshold 0.50. This is the control row for every later comparison.</li>
<li><strong>Cache the logits.</strong> <code>src.inference</code> stores the raw logits, not only the probabilities. That matters because temperature scaling is defined on logits: <code>sigmoid(logit / T)</code>. The cache is keyed by checkpoint hash and split.</li>
<li><strong>Fit parameters on validation only.</strong> <code>src.fit_parameters</code> fits one temperature per label by minimizing validation NLL. It then fits five threshold policies on validation predictions, once in raw probability space and once in calibrated probability space.</li>
<li><strong>Run the experiments.</strong> Experiments 1–4 measure calibration quality, threshold policies on raw and calibrated probabilities, and robustness to binning, constraint targets, and validation resampling.</li>
<li><strong>Measure uncertainty.</strong> The upgraded statistical layer uses a patient-level cluster bootstrap, paired permutation tests with Holm correction for the two primary hypotheses, and DeLong confidence intervals for test AUROC.</li>
<li><strong>Analyze failure modes.</strong> Error analysis computes confusion strata, high-confidence errors, cross-label overlap, and per-patient error concentration. Grad-CAM then runs on a deterministic case list and on a fixed cohort of test images that have NIH bounding boxes.</li>
<li><strong>Build the evidence package.</strong> <code>src.master_results</code>, <code>src.uncertainty</code>, <code>src.paper_artifacts</code>, and <code>src.paper_build</code> derive the master table, confidence intervals, figures, tables, evidence map, and claim audit from the frozen artifacts.</li>
<li><strong>Present.</strong> The Gradio dashboard reads the same artifacts. It does not recompute the research results.</li>
</ol>
<h3>The A/B/C/D decomposition</h3>
<p>The central experiment isolates the two knobs:</p>
<table>
<thead>
<tr>
<th>Arm</th>
<th>Calibration</th>
<th>Threshold</th>
<th align="right">Cardiomegaly F1</th>
<th align="right">Effusion F1</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>None</td>
<td>Fixed 0.50</td>
<td align="right">0.2836</td>
<td align="right">0.4676</td>
</tr>
<tr>
<td>B</td>
<td>Temperature</td>
<td>Fixed 0.50</td>
<td align="right">0.2836</td>
<td align="right">0.4676</td>
</tr>
<tr>
<td>C</td>
<td>None</td>
<td>Validation F1-optimal</td>
<td align="right">0.3536</td>
<td align="right">0.4866</td>
</tr>
<tr>
<td>D</td>
<td>Temperature</td>
<td>Validation F1-optimal</td>
<td align="right">0.3536</td>
<td align="right">0.4866</td>
</tr>
</tbody></table>
<p>Two results stand out:</p>
<ul>
<li><strong>Arm B equals arm A at threshold 0.50.</strong> Temperature scaling is monotone. For a positive temperature, <code>sigmoid(z / T) &gt;= 0.5</code> is equivalent to <code>sigmoid(z) &gt;= 0.5</code>, so the decisions at 0.50 are identical. The calibrated probabilities change; the fixed-threshold decisions do not.</li>
<li><strong>Arm D equals arm C in operating-point metrics.</strong> The thresholds were refitted in the calibrated space, so the chosen operating points land on the same confusion matrices. The numeric thresholds differ — raw F1-optimal thresholds are 0.9021 and 0.7699, calibrated F1-optimal thresholds are 0.8666 and 0.7035 — but the decisions are monotone-equivalent.</li>
</ul>
<p>The primary endpoint is the paired difference between arm D and arm A on test F1:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th align="right">ΔF1 (D − A)</th>
<th align="right">95% patient-bootstrap CI</th>
<th align="right">Holm-adjusted p</th>
</tr>
</thead>
<tbody><tr>
<td>Cardiomegaly</td>
<td align="right">+0.0700</td>
<td align="right">[0.0280, 0.1065]</td>
<td align="right">0.0004</td>
</tr>
<tr>
<td>Effusion</td>
<td align="right">+0.0191</td>
<td align="right">[0.0050, 0.0326]</td>
<td align="right">0.0262</td>
</tr>
</tbody></table>
<p>The confidence intervals come from 5,000 patient-level bootstrap resamples with a fixed seed. The bootstrap resamples patients, not individual images, so images from the same patient stay together. The permutation test uses the same paired structure, and Holm correction controls the family-wise error rate across the two declared primary hypotheses.</p>
<p>The practical interpretation is not "calibration improves F1." The result is more specific: <strong>choosing a validation-fitted operating point changes the decision metrics; temperature scaling changes the probability scale and the proper scoring rules, but it does not change ranking and does not by itself change decisions at a fixed 0.50 threshold.</strong></p>
<h3>Calibration: a deliberately honest result</h3>
<p>Temperature scaling is fitted on validation by minimizing negative log-likelihood. The fitted temperatures are:</p>
<ul>
<li>Cardiomegaly: <strong>1.1865</strong></li>
<li>Effusion: <strong>1.3979</strong></li>
</ul>
<p>On test, the proper scoring rules improve:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th align="right">NLL raw → calibrated</th>
<th align="right">Brier raw → calibrated</th>
<th align="right">ECE raw → calibrated</th>
</tr>
</thead>
<tbody><tr>
<td>Cardiomegaly</td>
<td align="right">0.2297 → 0.2262</td>
<td align="right">0.0681 → 0.0678</td>
<td align="right">0.1061 → 0.1169</td>
</tr>
<tr>
<td>Effusion</td>
<td align="right">0.4819 → 0.4690</td>
<td align="right">0.1561 → 0.1533</td>
<td align="right">0.2077 → 0.2292</td>
</tr>
</tbody></table>
<p>NLL and Brier go down. Binned ECE goes <strong>up</strong>. This is not hidden or rounded away. It is one of the most useful findings in the repository.</p>
<p>Why does ECE rise? Temperature scaling is optimized for NLL, not for ECE. In this cohort, the score distribution is heavily concentrated at low probabilities, and the mean confidence exceeds prevalence in the low-probability region. In that regime, ECE behaves approximately like <code>mean(probability) − prevalence</code>. For Cardiomegaly, the raw mean confidence is about 0.132 and the prevalence is 0.026, giving an ECE of roughly 0.106. After calibration, the mean confidence moves away from prevalence, so the binned ECE increases even though the likelihood improves. The repository reports both the improvement and the regression, and a test asserts that AUROC and AUPRC are exactly invariant to temperature.</p>
<p>This is a good example of why "calibration" should not be treated as a single number. NLL, Brier, and ECE answer different questions, and a monotone transform can improve one while worsening another.</p>
<h3>What the errors look like</h3>
<p>At the calibrated F1-optimal operating point, the error profile is asymmetric:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th align="right">Errors / test images</th>
<th align="right">Error rate</th>
<th align="right">Errors with confidence ≥ 0.90</th>
<th align="right">Share of that label's errors</th>
</tr>
</thead>
<tbody><tr>
<td>Cardiomegaly</td>
<td align="right">563 / 15,884</td>
<td align="right">3.54%</td>
<td align="right">274</td>
<td align="right">48.7%</td>
</tr>
<tr>
<td>Effusion</td>
<td align="right">2,268 / 15,884</td>
<td align="right">14.28%</td>
<td align="right">193</td>
<td align="right">8.5%</td>
</tr>
</tbody></table>
<p>The rare label is not just harder; its failures are more often confident failures. Cardiomegaly has far fewer errors in absolute terms, but almost half of them carry a score of at least 0.90. Effusion has four times as many errors, but only 8.5% are high-confidence.</p>
<p>The fixed-0.50 error analysis also shows that failures are correlated across labels and concentrated across patients. At threshold 0.50, 585 images are wrong on both labels, 4,449 are wrong on at least one, and the Jaccard overlap of the two error sets is 0.1315. The 4,449 error images come from 1,317 patients, and the worst patient accounts for 60 error images. That concentration is descriptive, not causal. It is a reason to use patient-level resampling and a reason to look at per-patient failure modes rather than treating every image as independent.</p>
<p>The Grad-CAM analysis adds a qualitative layer. On a deterministic set of 16 TP/FP/FN/TN cases, the heatmaps show where the model's final convolutional block is most active. On a separate fixed cohort of 43 test images that have NIH bounding boxes, the pointing game puts the heatmap argmax inside the box for 21 images (48.8%), and the mean heat-mass concentration ratio is 2.60, with 38 of 43 cases above 1. That is a sanity check that the maps are not random, not evidence of clinically valid localization.</p>
<h3>Live inference workflow</h3>
<p>The dashboard's live tab uses the frozen checkpoint when it is available. The flow is intentionally simple:</p>
<p><img src="https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBVc2VyCiAgICBwYXJ0aWNpcGFudCBVSSBhcyBHcmFkaW8gZGFzaGJvYXJkCiAgICBwYXJ0aWNpcGFudCBEUyBhcyBzcmMuZGF0YXNldAogICAgcGFydGljaXBhbnQgSU5GIGFzIHNyYy5pbmZlcmVuY2UKICAgIHBhcnRpY2lwYW50IE0gYXMgRGVuc2VOZXQxMjEKICAgIHBhcnRpY2lwYW50IENBTCBhcyBzcmMudWlfZGF0YQogICAgcGFydGljaXBhbnQgVEggYXMgZnJvemVuIHRocmVzaG9sZHMgSlNPTgoKICAgIFVzZXItPj5VSTogVXBsb2FkIGNoZXN0IFgtcmF5IGltYWdlCiAgICBVSS0-PklORjogbG9hZF9tb2RlbChmcm96ZW4gY2hlY2twb2ludCkKICAgIGFsdCBjaGVja3BvaW50IG1pc3NpbmcKICAgICAgICBJTkYtLT4-VUk6IEZpbGVOb3RGb3VuZEVycm9yCiAgICAgICAgVUktLT4-VXNlcjogTGl2ZSBpbmZlcmVuY2UgdW5hdmFpbGFibGU7IGFydGlmYWN0IHRhYnMgc3RpbGwgd29yawogICAgZWxzZSBjaGVja3BvaW50IHByZXNlbnQKICAgICAgICBJTkYtLT4-VUk6IG1vZGVsICsgbGFiZWwgb3JkZXIKICAgICAgICBVSS0-PkRTOiBidWlsZF90cmFuc2Zvcm1zKHRyYWluPUZhbHNlKQogICAgICAgIERTLS0-PlVJOiAyMjQgeCAyMjQgbm9ybWFsaXplZCB0ZW5zb3IKICAgICAgICBVSS0-Pk06IGZvcndhcmQodGVuc29yKQogICAgICAgIE0tLT4-VUk6IHJhdyBsb2dpdHMKICAgICAgICBVSS0-PkNBTDogdGVtcGVyYXR1cmVfcHJvYmFiaWxpdGllcyhsb2dpdHMsIFQpCiAgICAgICAgQ0FMLS0-PlVJOiBjYWxpYnJhdGVkIHByb2JhYmlsaXRpZXMKICAgICAgICBVSS0-PlRIOiByZWFkIGYxX29wdGltYWwgdGhyZXNob2xkcwogICAgICAgIFRILS0-PlVJOiB0YXUgcGVyIGxhYmVsCiAgICAgICAgVUktLT4-VXNlcjogcmF3IHNjb3JlLCBjYWxpYnJhdGVkIHNjb3JlLCB0aHJlc2hvbGQsIHByZWRpY3RlZCBsYWJlbAogICAgZW5kCiAgICBOb3RlIG92ZXIgVUksVXNlcjogUmVzZWFyY2ggZGVtb25zdHJhdGlvbiBvbmx5OyBub3QgYSBkaWFnbm9zaXM?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="Sequence diagram of the Gradio dashboard live-inference path from image upload through model loading, preprocessing, calibrated probabilities, frozen thresholds, and research-only results." /></p>
<p>The live path does not fit anything. It loads the frozen temperature values and the frozen calibrated F1-optimal thresholds, applies them, and returns a per-label table. The UI also reports when the checkpoint is unavailable; the artifact tabs continue to work from the stored JSON and CSV files.</p>
<p>The dashboard has tabs for the research overview, live X-ray demonstration, calibration, A/B/C/D decision policy, statistical evidence, threshold stability, error analysis, decision-policy comparison, prevalence sensitivity, and reproducibility/limitations. A "Presentation" mode hides source-path details, while a "Technical" mode exposes them. That split is useful for a faculty demo: the audience sees the evidence, while the presenter can reveal provenance when asked.</p>
<h2>Data flow and data model</h2>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBSQVdbIlJhdyBQTkdzICsgbWV0YWRhdGEgQ1NWIl0gLS0-IFNQTElUWyJGcm96ZW4gc3BsaXRfaW5kZXguY3N2PGJyLz5wYXRpZW50LWxldmVsIDcwLzE1LzE1Il0KICAgIFNQTElUIC0tPiBJTkZFUlsiTW9kZWwgaW5mZXJlbmNlPGJyLz5EZW5zZU5ldDEyMSBsb2dpdHMiXQogICAgSU5GRVIgLS0-IENBQ0hFWyJSYXcgcHJlZGljdGlvbiBjYWNoZTxici8-dHJ1ZSwgbG9naXQsIHByb2IgY29sdW1ucyJdCiAgICBDQUNIRSAtLT4gRklUWyJWYWxpZGF0aW9uLW9ubHkgZml0dGluZzxici8-dGVtcGVyYXR1cmVzICsgdGhyZXNob2xkcyJdCiAgICBDQUNIRSAtLT4gRVZBTFsiSGVsZC1vdXQgdGVzdCBldmFsdWF0aW9uIl0KICAgIEZJVCAtLT4gRVZBTAogICAgRVZBTCAtLT4gTUVUUklDU1siTWV0cmljcyBKU09OIGFuZCB0aWR5IENTViJdCiAgICBFVkFMIC0tPiBGSUdTWyJQbG90cywgdGFibGVzLCBHcmFkLUNBTSBvdmVybGF5cyJdCiAgICBFVkFMIC0tPiBTVEFUU1siQm9vdHN0cmFwIENJcywgRGVMb25nLCBwZXJtdXRhdGlvbiJdCiAgICBNRVRSSUNTIC0tPiBFVklERU5DRVsiZmluYWxfcmVzdWx0cyBhbmQgcGFwZXIgcGFja2FnZSJdCiAgICBGSUdTIC0tPiBFVklERU5DRQogICAgU1RBVFMgLS0-IEVWSURFTkNFCiAgICBFVklERU5DRSAtLT4gREFTSFsiR3JhZGlvIGRhc2hib2FyZCJdCiAgICBFVklERU5DRSAtLT4gQVJUSUNMRVsiVGVjaG5pY2FsIGFydGljbGUiXQ?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="Data-flow diagram from raw images and metadata through the patient-level split, model inference, cached predictions, validation fitting, test evaluation, metrics, figures, statistical results, dashboard, and article." /></p>
<p>The dataset has 109,312 usable images from 29,720 patients. The patient-level split is 70/15/15:</p>
<table>
<thead>
<tr>
<th>Split</th>
<th align="right">Images</th>
<th align="right">Patients</th>
<th align="right">Cardiomegaly positive</th>
<th align="right">Effusion positive</th>
</tr>
</thead>
<tbody><tr>
<td>Train</td>
<td align="right">76,977</td>
<td align="right">20,797</td>
<td align="right">1,880</td>
<td align="right">9,001</td>
</tr>
<tr>
<td>Validation</td>
<td align="right">16,451</td>
<td align="right">4,468</td>
<td align="right">392</td>
<td align="right">1,966</td>
</tr>
<tr>
<td>Test</td>
<td align="right">15,884</td>
<td align="right">4,455</td>
<td align="right">415</td>
<td align="right">1,997</td>
</tr>
</tbody></table>
<p>The split table's Effusion label sum for test is 1,996, while the cached prediction support is 1,997. One test image is a truncated PNG, and the dataset loader substitutes the next readable sample's label rather than crashing. This is a documented data defect in the frozen control, not a silent repair. The repository keeps the defect to preserve reproducibility of the baseline and records it in the audit documentation.</p>
<p>There is <strong>no relational database</strong> in this project. The data model is a file-based evidence store:</p>
<ul>
<li><strong>Raw prediction table</strong> (one CSV per split): <code>Image Index</code>, <code>Patient ID</code>, <code>split</code>, <code>true_&lt;label&gt;</code>, <code>logit_&lt;label&gt;</code>, <code>prob_&lt;label&gt;</code>.</li>
<li><strong>Frozen parameter files</strong>: <code>temperature_scalers.json</code>, <code>thresholds_val.json</code>, <code>thresholds_calibrated_val.json</code>.</li>
<li><strong>Result artifacts</strong>: JSON payloads for experiments, error analysis, statistics, and the research summary.</li>
<li><strong>Derived artifacts</strong>: tidy CSVs for tables and figures, plus PNG plots and Grad-CAM overlays.</li>
</ul>
<p>Storing logits is a deliberate choice. If the pipeline stored only probabilities, it would be impossible to apply a temperature transform later without re-running the model. Storing logits also makes the monotone-invariance checks direct and testable. The cache filename contains the checkpoint SHA-256 prefix, so the prediction table is tied to the exact model that produced it.</p>
<h2>Deep dive: validation-only temperature scaling</h2>
<p>The calibration code is a good example of a small module with a strong contract. The key guard is explicit:</p>
<pre><code class="language-python"># src/calibration.py
FORBIDDEN_FIT_SPLITS = ("test", "external", "chexpert")

split_l = str(split).lower()
if any(bad in split_l for bad in FORBIDDEN_FIT_SPLITS):
    raise ValueError(
        f"refusing to fit calibration on split '{split}': calibration may "
        "only be fitted on validation (or training) predictions"
    )
</code></pre>
<p>The fit itself is a one-dimensional bounded optimization over a scalar temperature per label:</p>
<pre><code class="language-python"># src/calibration.py
if np.unique(y).size &lt; 2 or np.allclose(logits, logits[0]):
    return 1.0

lo, hi = float(bounds[0]), float(bounds[1])
try:
    from scipy.optimize import minimize_scalar

    res = minimize_scalar(
        lambda t: _nll_for_temperature(logits, y, t),
        bounds=(lo, hi), method="bounded",
        options={"xatol": 1e-6},
    )
    if res.success and np.isfinite(res.fun):
        return float(res.x)
except Exception:
    pass
return _golden_section(lambda t: _nll_for_temperature(logits, y, t), lo, hi)
</code></pre>
<p>Three details matter:</p>
<ul>
<li><strong>One parameter per label.</strong> The model is multi-label, and Cardiomegaly and Effusion have very different prevalence. A shared temperature would force one calibration curve onto two different score distributions.</li>
<li><strong>Degenerate input returns T = 1.0.</strong> If a label has only one class in the fitting split, or all logits are identical, the optimizer has nothing meaningful to fit. Returning 1.0 means "leave the model alone" rather than inventing a correction.</li>
<li><strong>SciPy is optional at runtime.</strong> If <code>minimize_scalar</code> is unavailable or fails, a golden-section search with the same bounds is used. That keeps the module testable in a minimal environment.</li>
</ul>
<p>The monotone property is not just a mathematical footnote. It is the reason the A/B and C/D arms have identical decisions at the same threshold. It also means AUROC and AUPRC are exactly invariant to temperature, which the test suite asserts rather than assumes.</p>
<h2>Deep dive: threshold policies as deterministic search</h2>
<p>The threshold module implements five policies, all fitted on validation:</p>
<ul>
<li><code>fixed</code>: τ = 0.50.</li>
<li><code>f1_optimal</code>: the threshold that maximizes F1.</li>
<li><code>youden</code>: the threshold that maximizes Youden's J = sensitivity + specificity − 1.</li>
<li><code>sensitivity_constrained</code>: the largest threshold with sensitivity ≥ 0.90, which maximizes specificity subject to that recall floor.</li>
<li><code>precision_constrained</code>: the highest-recall threshold with precision ≥ 0.50, with a documented fallback if the target is unreachable.</li>
</ul>
<p>The F1 policy is short enough to read as a specification:</p>
<pre><code class="language-python"># src/thresholds.py
def threshold_f1(y_true: np.ndarray, y_prob: np.ndarray) -&gt; Dict[str, object]:
    """τ maximising F1; ties broken towards the larger τ (fewer false positives)."""
    y_true, y_prob = _as1d(y_true), _as1d(y_prob)
    best = {"threshold": 0.5, "f1": -1.0}
    for tau in _candidates(y_true, y_prob):
        m = binary_metrics(y_true, y_prob, float(tau))
        if m["f1"] &gt; best["f1"] + 1e-12 or (
            abs(m["f1"] - best["f1"]) &lt;= 1e-12 and tau &gt; best["threshold"]
        ):
            best = {"threshold": float(tau), "f1": m["f1"]}
    return {"threshold": best["threshold"], "constraint_met": None, "target": None}
</code></pre>
<p>The candidate set is the set of observed score values, not an arbitrary grid. That guarantees the chosen threshold reproduces a real confusion matrix from the data. Ties are broken toward the larger threshold, which favors fewer false positives. The constrained policies use the same candidate set but select the most specific feasible threshold or the highest-recall feasible threshold.</p>
<p>This design makes the policy outcomes easy to audit. For example, the sensitivity-constrained policy reaches validation recall 0.9005 for Cardiomegaly and 0.9003 for Effusion, and on test it transfers to recall 0.9205 and 0.8908. The precision-constrained policy reaches validation precision 0.5000 for both labels and transfers to test precision 0.5303 and 0.5157. The transfer is not perfect, but it is measured and reported.</p>
<p>The constrained policies also illustrate a trade-off that a single F1 number hides. A rule-out workflow may prefer high sensitivity even at the cost of many false positives. A referral workflow may prefer high precision even at the cost of missing cases. The repository does not declare one policy universally best; it reports the operating characteristics of each so the choice can be made explicitly.</p>
<h2>APIs, integrations, and services</h2>
<p>There is no HTTP API in this project. The public interface is a set of module-level command-line entry points plus the Gradio app. The main commands are:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>python -m src.inference --split test</code></td>
<td>Cache logits and probabilities for a split</td>
</tr>
<tr>
<td><code>python -m src.baseline_eval --split test</code></td>
<td>Evaluate the frozen baseline at 0.50</td>
</tr>
<tr>
<td><code>python -m src.fit_parameters</code></td>
<td>Fit temperatures and thresholds on validation</td>
</tr>
<tr>
<td><code>python -m src.experiments --exp all</code></td>
<td>Run experiments 1–4</td>
</tr>
<tr>
<td><code>python -m src.error_analysis --split test</code></td>
<td>Compute error strata and the Grad-CAM case list</td>
</tr>
<tr>
<td><code>python -m src.gradcam</code></td>
<td>Generate Grad-CAM overlays and the bbox cohort</td>
</tr>
<tr>
<td><code>python -m src.external_eval --status</code></td>
<td>Refresh the external-validation status</td>
</tr>
<tr>
<td><code>python -m src.master_results</code></td>
<td>Build the canonical master results table</td>
</tr>
<tr>
<td><code>python -m src.uncertainty</code></td>
<td>Build bootstrap confidence intervals</td>
</tr>
<tr>
<td><code>python -m src.paper_artifacts</code></td>
<td>Build tables, figures, and the research summary</td>
</tr>
<tr>
<td><code>python -m src.paper_build</code></td>
<td>Build the paper package and evidence map</td>
</tr>
<tr>
<td><code>python research_demo_app.py</code></td>
<td>Launch the read-only dashboard</td>
</tr>
</tbody></table>
<p>The only external integration is the CheXpert pipeline. It looks for <code>valid.csv</code> and <code>images/</code> under <code>data/raw/chexpert/</code>, then under <code>$CHEXPERT_ROOT</code> or <code>$CHEXPERT_DATA_DIR</code>, then under <code>/mnt/data/chexpert</code> and <code>/data/chexpert</code>. The label mapping is explicit: NIH <code>Effusion</code> maps to CheXpert <code>Pleural Effusion</code>. The default uncertainty policy is <code>u_zeroes</code>, which maps CheXpert's <code>-1</code> uncertain label to absent.</p>
<p>Because the external cohort is absent, the pipeline writes only a status file with <code>no_metrics_were_computed: true</code>. There is no fabricated external metric, prediction file, or plot. That behavior is enforced by tests.</p>
<h2>Local setup and installation</h2>
<p>The documented environment uses Python 3.14.7 and a project virtual environment. The system Python on the original machine did not have PyTorch, so every command in the repository uses <code>.venv/bin/python</code> explicitly.</p>
<h3>Prerequisites</h3>
<ul>
<li>A recent Python 3 environment.</li>
<li>A virtual environment tool (<code>venv</code>).</li>
<li>Optional: a CUDA-capable GPU for faster inference and Grad-CAM. CPU inference works, but is slower.</li>
<li>The NIH ChestX-ray14 images and <code>Data_Entry_2017.csv</code> if you want to re-run inference or training. The repository's research artifacts can be inspected without the raw dataset.</li>
</ul>
<h3>Clone and install</h3>
<pre><code class="language-bash">git clone https://github.com/vishalkumarojha/Heart-imaging-modelilty.git
cd Heart-imaging-modelilty

python -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
</code></pre>
<p>For the dashboard, install the demo requirements on top of the base requirements:</p>
<pre><code class="language-bash">pip install -r requirements_demo.txt
</code></pre>
<h3>Data layout</h3>
<p>Place the NIH data under <code>data/raw/</code>:</p>
<pre><code class="language-text">data/raw/
├── Data_Entry_2017.csv
├── BBox_List_2017.csv          # optional, used by Grad-CAM localization
└── images_001/ ... images_012/
</code></pre>
<p>The processed split cache is <code>data/processed/split_index.csv</code>. If it is missing, the dataset code will rebuild it from the metadata and images, but the research artifacts in this repository were produced with the frozen split.</p>
<h3>Verify the frozen artifacts</h3>
<pre><code class="language-bash">.venv/bin/python -m unittest discover -s tests
</code></pre>
<p>The current checkout runs 217 tests. The frozen snapshot in <code>outputs/research_snapshot.json</code> records 193 tests at the time the snapshot was created; the difference reflects tests added after that freeze. The suite is designed to validate artifacts and pure functions without loading the model or touching the GPU.</p>
<h3>Reproduce the research pipeline</h3>
<pre><code class="language-bash"># 1. Cache raw logits and probabilities
.venv/bin/python -m src.inference --split test
.venv/bin/python -m src.inference --split val

# 2. Evaluate the frozen baseline
.venv/bin/python -m src.baseline_eval --split test
.venv/bin/python -m src.baseline_eval --split val

# 3. Fit temperatures and thresholds on validation only
.venv/bin/python -m src.fit_parameters

# 4. Run experiments 1-4
.venv/bin/python -m src.experiments --exp all

# 5. Error analysis and Grad-CAM
.venv/bin/python -m src.error_analysis --split test
.venv/bin/python -m src.gradcam

# 6. External validation status (expected: blocked until data exists)
.venv/bin/python -m src.external_eval --status

# 7. Paper-ready evidence
.venv/bin/python -m src.master_results
.venv/bin/python -m src.uncertainty
.venv/bin/python -m src.paper_artifacts
.venv/bin/python -m src.paper_build
</code></pre>
<h3>Launch the dashboard</h3>
<pre><code class="language-bash">.venv/bin/python research_demo_app.py
</code></pre>
<p>Open <code>http://127.0.0.1:7860</code>. To use another port:</p>
<pre><code class="language-bash">GRADIO_SERVER_PORT=7861 .venv/bin/python research_demo_app.py
</code></pre>
<p>The dashboard binds to localhost by default. It does not require GitHub or an external service.</p>
<h3>Environment variables</h3>
<p>The project does not load a <code>.env</code> file. Set variables in the shell when needed:</p>
<pre><code class="language-bash"># Optional: point external validation at a CheXpert mount
export CHEXPERT_ROOT=/path/to/chexpert
export CHEXPERT_DATA_DIR=/path/to/chexpert

# Optional: change the Gradio port
export GRADIO_SERVER_PORT=7861
</code></pre>
<h3>Troubleshooting</h3>
<ul>
<li><strong><code>ModuleNotFoundError: torch</code></strong>: use <code>.venv/bin/python</code>, not the system <code>python3</code>.</li>
<li><strong>Live inference says the checkpoint is unavailable</strong>: the artifact tabs still work. Place the frozen checkpoint at <code>outputs/checkpoints/baseline/densenet121_best.pt</code> if you have it.</li>
<li><strong>Raw data missing</strong>: inference and training need the NIH images. The stored research artifacts can still be inspected and many tests still run.</li>
<li><strong><code>pytest</code> not found</strong>: the project uses <code>unittest</code>; run <code>python -m unittest discover -s tests</code>.</li>
<li><strong>External validation is blocked</strong>: that is the expected state until a CheXpert cohort is present. The status file explains every path that was probed.</li>
<li><strong>Gradio port already in use</strong>: set <code>GRADIO_SERVER_PORT</code> to a free port.</li>
<li><strong>Small differences after recomputing inference</strong>: AMP fp16 can flip a few rows near 0.50. The project uses one cached prediction table for all experiments so the difference does not compound.</li>
</ul>
<h2>Testing and reliability</h2>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBUWyJ1bml0dGVzdCBkaXNjb3ZlciAtcyB0ZXN0cyJdIC0tPiBBWyJDaGVja3BvaW50IGFuZCBzcGxpdCBpbnRlZ3JpdHkiXQogICAgVCAtLT4gQlsiTWV0cmljIHJlY29tcHV0YXRpb24gZnJvbSBhcnRpZmFjdHMiXQogICAgVCAtLT4gQ1siVmFsaWRhdGlvbi1vbmx5IGZpdHRpbmcgZ3VhcmRzIl0KICAgIFQgLS0-IERbIk1vbm90b25lIGludmFyaWFuY2UgY2hlY2tzIl0KICAgIFQgLS0-IEVbIkRldGVybWluaXN0aWMgR3JhZC1DQU0gY2FzZSBsaXN0Il0KICAgIFQgLS0-IEZbIkV4dGVybmFsLXZhbGlkYXRpb24gaG9uZXN0eSJdCiAgICBUIC0tPiBHWyJQYXBlciB0YWJsZS9maWd1cmUgY29uc2lzdGVuY3kiXQogICAgQSAtLT4gUlsiUGFzcyBvciBmYWlsIl0KICAgIEIgLS0-IFIKICAgIEMgLS0-IFIKICAgIEQgLS0-IFIKICAgIEUgLS0-IFIKICAgIEYgLS0-IFIKICAgIEcgLS0-IFI?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="Testing workflow showing unittest checks for checkpoint and split integrity, metric recomputation, validation-only fitting guards, monotone invariance, deterministic Grad-CAM cases, external-validation honesty, and paper artifact consistency." /></p>
<p>The test suite is not a token smoke test. It checks:</p>
<ul>
<li>the frozen checkpoint hashes and the presence of the legacy root checkpoint;</li>
<li>the patient-level split integrity and documented row counts;</li>
<li>that published metrics recompute from the stored prediction tables;</li>
<li>that calibration and threshold fitting refuse evaluation splits;</li>
<li>that AUROC and AUPRC are invariant under temperature scaling;</li>
<li>that the Grad-CAM case list is deterministic and every case image exists;</li>
<li>that the external phase remains honest while the dataset is absent;</li>
<li>that the paper tables, figures, and claim audit agree with their source artifacts.</li>
</ul>
<p>Reliability mechanisms in the code include:</p>
<ul>
<li><strong>Atomic JSON writes</strong> (<code>tmp</code> file + replace) so a crash does not leave a truncated result.</li>
<li><strong>NaN for undefined metrics</strong> instead of raising or silently returning zero.</li>
<li><strong>Actionable <code>FileNotFoundError</code> messages</strong> that name the missing artifact and the command that produces it.</li>
<li><strong>Runtime guards</strong> on validation-only fitting.</li>
<li><strong>Hash-keyed prediction caches</strong> so a checkpoint change invalidates old predictions.</li>
<li><strong>Deterministic case selection</strong> for Grad-CAM, ordered by certainty and image index with no RNG.</li>
</ul>
<p>There is no CI workflow in the repository. The tests are run locally. The frozen snapshot records a passing run, and the current checkout passes when run with <code>.venv/bin/python -m unittest discover -s tests</code>.</p>
<h2>Security and privacy considerations</h2>
<p>This is a local research tool, not a deployed clinical service. The security posture is correspondingly simple:</p>
<ul>
<li>The Gradio app binds to <code>127.0.0.1</code>, not to a public interface.</li>
<li>Uploaded images are processed locally; the app does not send them to an external API.</li>
<li>There are no API keys, tokens, or secrets in the repository.</li>
<li>The external CheXpert integration reads a local filesystem path; it does not authenticate to a remote service.</li>
<li>The research artifacts contain image indices and patient IDs from a public, de-identified dataset.</li>
</ul>
<p>Those points are not a claim that the system is "secure" in a clinical sense. If the dashboard were exposed beyond localhost, it would need authentication, TLS, access control, audit logging, retention policies, and a careful review of any protected health information. The repository explicitly labels the system as a research demonstration and not clinical software.</p>
<h2>Deployment and infrastructure</h2>
<p><img src="https://mermaid.ink/img/Zmxvd2NoYXJ0IFRCCiAgICBzdWJncmFwaCBXb3Jrc3RhdGlvblsiTG9jYWwgd29ya3N0YXRpb24gKGRvY3VtZW50ZWQgZW52aXJvbm1lbnQpIl0KICAgICAgICBQWVsiLnZlbnY8YnIvPlB5dGhvbiAzLjE0LjciXQogICAgICAgIEdQVVsiTlZJRElBIEdUWCAxNjUwPGJyLz5DVURBIDEzLjAgKG9wdGlvbmFsKSJdCiAgICAgICAgQVBQWyJyZXNlYXJjaF9kZW1vX2FwcC5weTxici8-R3JhZGlvIHNlcnZlciJdCiAgICAgICAgQVJUWyJvdXRwdXRzLzxici8-cmVhZC1vbmx5IHJlc2VhcmNoIGFydGlmYWN0cyJdCiAgICAgICAgQ0tQVFsiYmFzZWxpbmUgY2hlY2twb2ludDxici8-RGVuc2VOZXQxMjEiXQogICAgICAgIERBVEFbImRhdGEvcmF3Lzxici8-TklIIENoZXN0WC1yYXkxNCAoZ2l0LWlnbm9yZWQpIl0KICAgIGVuZAoKICAgIEJST1dTRVJbIkJyb3dzZXI8YnIvPmh0dHA6Ly8xMjcuMC4wLjE6Nzg2MCJdCiAgICBCUk9XU0VSIC0tPnxsb2NhbGhvc3QgSFRUUHwgQVBQCiAgICBBUFAgLS0-IFBZCiAgICBBUFAgLS0-IEFSVAogICAgQVBQIC0tPiBDS1BUCiAgICBBUFAgLS4tPnxsaXZlIGluZmVyZW5jZXwgR1BVCiAgICBEQVRBIC0uLT58aW5mZXJlbmNlIGFuZCBldmFsdWF0aW9ufCBBUFA?type=png&amp;theme=neutral&amp;bgColor=ffffff" alt="Local deployment architecture showing the workstation Python environment, optional GPU, Gradio server, local research artifacts, checkpoint, NIH data, and browser access over localhost." /></p>
<p>The current deployment model is a local workstation. The dashboard is a Gradio process launched from the repository root. The model checkpoint, data, and result artifacts are local files. The raw dataset and checkpoints are git-ignored because they are large; a fresh clone needs them provisioned separately to reproduce inference.</p>
<p>There is <strong>no Dockerfile, no Kubernetes manifest, no cloud infrastructure, and no CI/CD pipeline</strong> in the repository. That is an honest description of the current state, not an omission in this article. The project was built to produce a reproducible research package on a single machine, and the local dashboard reflects that.</p>
<p>A future deployment could containerize the dashboard, put it behind an authenticated reverse proxy, store artifacts in an object store, and add a CI job that runs the test suite and verifies the frozen hashes. Those are proposed improvements, not existing infrastructure.</p>
<h2>Engineering challenges and trade-offs</h2>
<p><strong>Frozen model vs. flexibility.</strong> Freezing the checkpoint makes the calibration and threshold comparison clean, but it also means the project cannot explore whether a different backbone or training recipe would change the conclusions. The trade-off is deliberate: the research question is about the decision layer, not the model.</p>
<p><strong>Validation-only fitting vs. data efficiency.</strong> Fitting temperatures and thresholds on validation only is the right experimental discipline, but it leaves less data for parameter estimation. The constrained policies can miss their targets on validation, and the project reports that rather than tuning on test.</p>
<p><strong>NLL vs. ECE.</strong> Temperature scaling improves NLL and Brier while raising binned ECE. The project could have optimized a different objective or chosen a different calibration method, but the honest result is more useful than a metric that only goes in the desired direction. The repository keeps both numbers visible.</p>
<p><strong>F1-optimal vs. clinical operating points.</strong> F1-optimal is a reasonable default for a balanced summary, but it is not a clinical policy. The constrained sensitivity and precision policies show the trade-offs, and the project does not claim that one is universally correct.</p>
<p><strong>Image-level vs. patient-level statistics.</strong> The older confidence-interval pass resampled test images. The upgraded analysis resamples patients, which is the statistically defensible unit because images from one patient are correlated. Both artifacts exist; the legacy one is labeled as legacy, and the primary endpoint uses the patient-level bootstrap.</p>
<p><strong>External validity.</strong> The external pipeline is implemented, but the cohort is absent. The project refuses to estimate external performance. That is a limitation, but it is also a strength: the status file says exactly what is missing and how to unblock it.</p>
<p><strong>Explainability.</strong> Grad-CAM is qualitative. The repository adds a quantitative sanity check on a fixed cohort of 43 test images with NIH bounding boxes: the pointing game hits 21/43 (48.8%), and the heat-mass concentration ratio is 2.60 on average, with 38/43 cases above 1. That is evidence that the heatmaps are not random, but it is not a validation of clinical localization. The article and the dashboard both keep that caveat.</p>
<h2>Current limitations and future improvements</h2>
<p>Verified limitations:</p>
<ul>
<li>One patient-level split, one seed, one training run. There is no cross-validation.</li>
<li>Only two of the fourteen NIH findings are modeled.</li>
<li>The local image collection is partial: 109,312 images are available from the 112,120 metadata rows, so 2,808 images are missing.</li>
<li>NIH labels are report-mined, not radiologist-adjudicated for this study. Label noise is a real possibility.</li>
<li>The split is the project's own patient-level 70/15/15, not the official NIH split lists. Absolute numbers are not directly comparable to papers that use the official split.</li>
<li>External validation is pending; no external metrics exist.</li>
<li>Binned ECE rises after temperature scaling, even though NLL and Brier improve.</li>
<li>The legacy image-level bootstrap remains in the repository alongside the patient-level upgrade.</li>
<li>Grad-CAM localization is a small, fixed cohort sanity check, not a clinical validation.</li>
<li>There is no CI pipeline, no container image, and no public deployment.</li>
<li>The dashboard is read-only over precomputed artifacts; it does not support live research runs.</li>
</ul>
<p>Reasonable future improvements:</p>
<ul>
<li>Add an external cohort and run the already-implemented CheXpert evaluation with the frozen model and thresholds.</li>
<li>Add multi-seed or cross-validated training runs if the research question expands beyond the frozen-control design.</li>
<li>Extend the label set beyond Cardiomegaly and Effusion.</li>
<li>Compare temperature scaling with Platt scaling, isotonic regression, or beta calibration under the same validation-only protocol.</li>
<li>Add decision-curve analysis and cost-sensitive operating points for specific workflows.</li>
<li>Add CI that runs the test suite, verifies checkpoint hashes, and renders the Mermaid diagrams.</li>
<li>Containerize the dashboard and add authentication, TLS, and audit logging before exposing it beyond localhost.</li>
<li>Add live Grad-CAM with a clear non-clinical caveat, or keep it as a saved exploratory artifact.</li>
</ul>
<h2>What developers can learn from this project</h2>
<ul>
<li><strong>Separate ranking from deciding.</strong> AUROC and AUPRC measure discrimination; calibration and thresholds determine decisions. A model can be good at one and bad at the other.</li>
<li><strong>Monotone transforms preserve ranking but change probability scale.</strong> Temperature scaling cannot improve AUROC, and it cannot change decisions at a fixed 0.50 threshold. If you change the probability space, refit the threshold in that space.</li>
<li><strong>Fit parameters on validation and enforce it in code.</strong> A documentation rule is easy to break. A runtime <code>ValueError</code> plus tests is harder to ignore.</li>
<li><strong>Cache the model outputs, not just the metrics.</strong> Storing logits lets you re-derive calibrated probabilities without re-running the model and keeps every experiment on the same predictions.</li>
<li><strong>Treat artifacts as the source of truth.</strong> JSON for canonical results, CSV for tidy tables, PNGs for derived views. Tests can then recompute published numbers from the same files.</li>
<li><strong>Use the right resampling unit.</strong> Patient-level clustering matters in medical imaging. A patient-level bootstrap is more defensible than resampling images independently.</li>
<li><strong>Report the metric that got worse.</strong> The ECE increase after temperature scaling is more informative than a selectively reported improvement.</li>
<li><strong>Build the demo on frozen evidence.</strong> A dashboard that reads verified artifacts is easier to trust and easier to test than a dashboard that recomputes research results on every click.</li>
</ul>
<h2>Conclusion</h2>
<p>The repository starts from a familiar situation: a chest X-ray classifier with good ranking metrics and no clear decision policy. It responds with a controlled study of the decision layer. A frozen DenseNet121 is evaluated at a fixed baseline, its logits are cached once, temperature scaling and five threshold policies are fitted on validation only, and the resulting operating points are evaluated on held-out test data. The A/B/C/D decomposition shows that threshold selection changes F1, while temperature scaling improves NLL and Brier without changing ranking or fixed-threshold decisions. The project also reports the uncomfortable result: binned ECE rises after calibration.</p>
<p>The engineering value is in the discipline around the numbers: one checkpoint, one prediction cache, validation-only fitting guards, patient-level statistical inference, deterministic error and Grad-CAM case selection, artifact-level tests, and a dashboard that reads the verified evidence instead of inventing a second version of it. The external-validation question remains open, and the repository says so explicitly.</p>
<p>If you are building a model-evaluation pipeline, the most reusable idea here is not a particular calibration method. It is the separation of <strong>parameters</strong>, <strong>results</strong>, and <strong>presentation</strong> — and the willingness to report a metric that moves in the wrong direction when the evidence says it does.</p>
<h2>References and further reading</h2>
<ul>
<li>Wang, X., Peng, Y., Lu, L., Lu, Z., Bagheri, M., &amp; Summers, R. M. (2017). ChestX-ray8: Hospital-scale Chest X-ray Database and Benchmarks on Weakly-Supervised Classification and Localization of Common Thorax Diseases. CVPR. arXiv:1705.02315.</li>
<li>Huang, G., Liu, Z., van der Maaten, L., &amp; Weinberger, K. Q. (2017). Densely Connected Convolutional Networks. CVPR. arXiv:1608.06993.</li>
<li>Guo, C., Pleiss, G., Sun, Y., &amp; Weinberger, K. Q. (2017). On Calibration of Modern Neural Networks. ICML. arXiv:1706.04599.</li>
<li>DeLong, E. R., DeLong, D. M., &amp; Clarke-Pearson, D. L. (1988). Comparing the areas under two or more correlated receiver operating characteristic curves: a nonparametric approach. Biometrics, 44(3), 837–845.</li>
<li>Holm, S. (1979). A simple sequentially rejective multiple test procedure. Scandinavian Journal of Statistics, 6(2), 65–70.</li>
<li>Selvaraju, R. R., Cogswell, M., Das, A., Vedantam, R., Parikh, D., &amp; Batra, D. (2017). Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization. ICCV. arXiv:1610.02391.</li>
<li>Irvin, J., et al. (2019). CheXpert: A Large Chest Radiograph Dataset with Uncertainty Labels and Expert Comparison. AAAI. arXiv:1901.07031.</li>
</ul>
<h2>Project links</h2>
<ul>
<li><strong>Source code:</strong> <a href="https://github.com/vishalkumarojha/Heart-imaging-modelilty">github.com/vishalkumarojha/Heart-imaging-modelilty</a></li>
<li><strong>Research dashboard:</strong> local only; launch with <code>python research_demo_app.py</code></li>
<li><strong>Documentation:</strong> the repository's <code>docs/</code> directory contains the scope, baseline, experiment, reproducibility, and status reports</li>
<li><strong>External validation:</strong> not available; see <code>outputs/metrics/external/status.json</code></li>
</ul>
]]></content:encoded></item></channel></rss>