Declarative and Imperative Prompt Engineering for Generative AI

-

refers back to the careful design and optimization of inputs (e.g., queries or instructions) for guiding the behavior and responses of generative AI models. Prompts are typically structured using either the declarative or imperative paradigm, or a combination of each. The alternative of paradigm can have a huge impact on the accuracy and relevance of the resulting model output. This text provides a conceptual overview of declarative and imperative prompting, discusses benefits and limitations of every paradigm, and considers the sensible implications.

The What and the How

In easy terms, declarative prompts express needs to be done, while imperative prompts specify something needs to be done. Suppose you might be at a pizzeria with a friend. You tell the waiter that you’re going to have the Neapolitan. Because you only mention the kind of pizza you would like without specifying exactly how you would like it prepared, that is an example of a declarative prompt. Meanwhile, your friend — who has some very particular culinary preferences and is within the mood for a bespoke pizza — proceeds to inform the waiter exactly how she would really like it made; that is an example of an imperative prompt.

Declarative and imperative paradigms of expression have an extended history in computing, with some programming languages favoring one paradigm over the opposite. A language comparable to C tends for use for imperative programming, while a language like Prolog is geared towards declarative programming. For instance, consider the next problem of identifying the ancestors of an individual named Charlie. We occur to know the next facts about Charlie’s relatives: Bob is Charlie’s parent, Alice is Bob’s parent, Susan is Dave’s parent, and John is Alice’s parent. Based on this information, the code below shows how we will discover Charlie’s ancestors using Prolog.

parent(alice, bob).
parent(bob, charlie).
parent(susan, dave).
parent(john, alice).

ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

get_ancestors(Person, Ancestors) :- findall(X, ancestor(X, Person), Ancestors).

?- get_ancestors(charlie, Ancestors).

Although the Prolog syntax could appear strange at first, it actually expresses the issue we wish to resolve in a concise and intuitive way. First, the code lays out the known facts (i.e., who’s whose parent). It then recursively defines the predicate ancestor(X, Y), which evaluates to true if X is an ancestor of Y. Finally, the predicate findall(X, Goal, List) triggers the Prolog interpreter to repeatedly evaluate Goal and store all successful bindings of X in List. In our case, this implies identifying all solutions to ancestor(X, Person) and storing them within the variable Ancestors. Notice that we don’t specify the implementation details (the “how”) of any of those predicates (the “what”).

In contrast, the C implementation below identifies Charlie’s ancestors by describing in painstaking detail exactly how this needs to be done.

#include 
#include 

#define MAX_PEOPLE 10
#define MAX_ANCESTORS 10

// Structure to represent parent relationships
typedef struct {
    char parent[20];
    char child[20];
} ParentRelation;

ParentRelation relations[] = {
    {"alice", "bob"},
    {"bob", "charlie"},
    {"susan", "dave"},
    {"john", "alice"}
};

int numRelations = 4;

// Check if X is a parent of Y
int isParent(const char *x, const char *y) {
    for (int i = 0; i < numRelations; ++i) {
        if (strcmp(relations[i].parent, x) == 0 && strcmp(relations[i].child, y) == 0) {
            return 1;
        }
    }
    return 0;
}

// Recursive function to examine if X is an ancestor of Y
int isAncestor(const char *x, const char *y) {
    if (isParent(x, y)) return 1;
    for (int i = 0; i < numRelations; ++i) {
        if (strcmp(relations[i].child, y) == 0) {
            if (isAncestor(x, relations[i].parent)) return 1;
        }
    }
    return 0;
}

// Get all ancestors of an individual
void getAncestors(const char *person, char ancestors[][20], int *numAncestors) {
    *numAncestors = 0;
    for (int i = 0; i < numRelations; ++i) {
        if (isAncestor(relations[i].parent, person)) {
            strcpy(ancestors[*numAncestors], relations[i].parent);
            (*numAncestors)++;
        }
    }
}

int foremost() {
    char person[] = "charlie";
    char ancestors[MAX_ANCESTORS][20];
    int count;

    getAncestors(person, ancestors, &count);

    printf("Ancestors of %s:n", person);
    for (int i = 0; i < count; ++i) {
        printf("%sn", ancestors[i]);
    }

    return 0;
}

Nowadays, the increasing availability of feature-rich libraries and APIs, and the consolidation of architectural best practices (e.g., programming to interfaces, using configuration over code), allows us to give attention to the “what” without having to fret concerning the “how” in lots of cases. Indeed, as suggested within the short video below, lots of code can — and maybe should — be written in a declarative manner.

From Programming to Prompting

If we predict of prompt engineering as the subsequent evolutionary step in programming — tracing the journey from machine-centric languages comparable to Assembly and C to more human-centric languages like Java and Python — then the relevance of declarative and imperative paradigms to prompt engineering becomes apparent. Although we will now instruct a generative AI model simply using natural language, we still need to make your mind up on the extent to which the instruction should express is to be achieved versus it’s to be achieved.

Consider the scenario of a developer who would really like to make use of an AI assistant to generate code — specifically, Python code to compute the cosine distance between two vectors. Following are three versions of a prompt that the developer might write to attain her goal. Version 1 is declarative, Version 2 is imperative, and Version 3 is a hybrid of the 2. The code generated by OpenAI’s GPT-4 model in response to every of those prompts can also be provided.

Prompt Version 1 (Declarative)

Result:

import numpy as np

def cosine_distance(u, v):
    u = np.array(u)
    v = np.array(v)
    return 1 - np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v))

Prompt Version 2 (Imperative)

Result:

import math

def cosine_distance(v1, v2):
    # Step 1: Input validation
    if not v1 or not v2:
        raise ValueError("Input vectors must not be empty.")
    if len(v1) != len(v2):
        raise ValueError("Vectors have to be of the identical length.")

    # Step 2: Compute dot product
    dot_product = sum(a * b for a, b in zip(v1, v2))

    # Step 3: Compute magnitudes
    mag_v1 = math.sqrt(sum(a ** 2 for a in v1))
    mag_v2 = math.sqrt(sum(b ** 2 for b in v2))

    if mag_v1 == 0 or mag_v2 == 0:
        raise ValueError("Vectors must not have zero magnitude.")

    # Step 4 & 5: Compute cosine similarity and distance
    cosine_similarity = dot_product / (mag_v1 * mag_v2)
    cosine_distance = 1 - cosine_similarity

    # Step 6: Return result
    return cosine_distance

Prompt Version 3 (Hybrid)

Result:

import numpy as np
from scipy.spatial.distance import cosine

def cosine_distance(v1, v2):
    # Convert inputs to NumPy arrays
    v1 = np.asarray(v1, dtype=np.float64)
    v2 = np.asarray(v2, dtype=np.float64)

    # Validation checks
    if v1.size == 0 or v2.size == 0:
        raise ValueError("Input vectors have to be non-empty.")
    if v1.shape != v2.shape:
        raise ValueError("Vectors should have the identical shape.")
    if not np.any(v1) or not np.any(v2):
        raise ValueError("Vectors must not be zero vectors.")

    # Compute cosine distance using optimized function
    return cosine(v1, v2)

Horses for Courses

The three prompts and their respective AI-generated implementations shown above imply different trade-offs and should be suited to different personas and scenarios in practice.

The declarative prompt (Version 1) is brief and easy. It doesn’t specify details of the precise algorithmic approach to be taken, expressing as an alternative the high-level task only. As such, it promotes creativity and suppleness in implementation. The downside of such a prompt, in fact, is that the result may not all the time be reproducible or robust; within the above case, the code generated by the declarative prompt could vary significantly across inference calls, and doesn’t handle edge cases, which could possibly be an issue if the code is meant to be used in production. Despite these limitations, typical personas who may favor the declarative paradigm include product managers, UX designers, and business domain experts who lack coding expertise and should not need production-grade AI responses. Software developers and data scientists may use declarative prompting to quickly generate a primary draft, but they might be expected to review and refine the code afterward. In fact, one must consider that the time needed to enhance AI-generated code may cancel out the time saved by writing a brief declarative prompt in the primary place.

Against this, the imperative prompt (Version 2) leaves little or no to probability — each algorithmic step is laid out in detail. Dependencies on non-standard packages are explicitly avoided, which might sidestep certain problems in production (e.g., breaking changes or deprecations in third-party packages, difficulty debugging strange code behavior, exposure to security vulnerabilities, installation overhead). However the greater control and robustness come at the associated fee of a verbose prompt, which could also be almost as effort-intensive as writing the code directly. Typical personas who go for imperative prompting may include software developers and data scientists. While they’re quite able to writing the actual code from scratch, they could find it more efficient to feed pseudocode to a generative AI model as an alternative. For instance, a Python developer might use pseudocode to quickly generate code in a special and fewer familiar programming language, comparable to C++ or Java, thereby reducing the likelihood of syntactic errors and the time spent debugging them.

Finally, the hybrid prompt (Version 3) seeks to mix one of the best of each worlds, using imperative instructions to repair key implementation details (e.g., stipulating using NumPy and SciPy), while otherwise employing declarative formulations to maintain the general prompt concise and straightforward to follow. Hybrid prompts offer freedom inside a framework, guiding the implementation without completely locking it in. Typical personas who may lean toward a hybrid of declarative and imperative prompting include senior developers, data scientists, and solution architects. For instance, within the case of code generation, an information scientist might need to optimize an algorithm using advanced libraries that a generative AI model may not select by default. Meanwhile, an answer architect may have to explicitly steer the AI away from certain third-party components to comply with architectural guidelines.

Ultimately, the alternative between declarative and imperative prompt engineering for generative AI needs to be a deliberate one, weighing the professionals and cons of every paradigm within the given application context.

ASK ANA

What are your thoughts on this topic?
Let us know in the comments below.

0 0 votes
Article Rating
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

Share this article

Recent posts

0
Would love your thoughts, please comment.x
()
x